Compare commits
17
Commits
v0.6.0
..
5402a50742
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5402a50742 | ||
|
|
f9fe6e0fa5 | ||
|
|
8acf85ddb8 | ||
|
|
cfaa773cf7 | ||
|
|
53dca6b4b8 | ||
|
|
4b407281e2 | ||
|
|
a7cb2505d7 | ||
|
|
ce0207c41f | ||
|
|
37c02a8a7b | ||
|
|
0377cd4abb | ||
|
|
370d3e550e | ||
|
|
8537a3cf0a | ||
|
|
ac8b62208c | ||
|
|
6df4bcb079 | ||
|
|
1b30a079e6 | ||
|
|
cdaadffc89 | ||
|
|
ea59db5acd |
+104
@@ -4,6 +4,110 @@ 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.8.1] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- The Font of a text stage (and of a text style) is picked from the fonts
|
||||
installed on this machine with a font chooser, rather than typed by name.
|
||||
The command line still takes any family, as `--font-family "DejaVu Serif"`.
|
||||
- Undo and redo: Ctrl+Z steps back through the edits to the project (the
|
||||
chain, params, styles, canvas, video settings, seed and images), and Ctrl+Y
|
||||
or Ctrl+Shift+Z steps forward again; both are in the menu as well. A burst
|
||||
of edits, like a spin held down or a word typed, is one step.
|
||||
|
||||
## [0.8.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- Tile swap, a video effect: tiles cut from the photographs are laid over the
|
||||
canvas as Tiles lays them, and each one is swapped for another cut at a
|
||||
moment of its own, so the mosaic keeps changing piece by piece. On the
|
||||
command line it is `-e tileswap` with `--tileswap-rate`,
|
||||
`--tileswap-layout` and the rest.
|
||||
- Echo tunnel, a video effect: the picture nested inside itself as Feedback
|
||||
nests it, flying into the echoes (or backing out of them) without end, a
|
||||
new echo fading in at the far end as the nearest melts into the picture.
|
||||
On the command line it is `-e tunnel` with `--tunnel-speed`,
|
||||
`--tunnel-scale`, `--tunnel-direction` and the rest.
|
||||
- Warp flow, a video effect: the picture pushed or swirled through a field
|
||||
of noise as Warp does, with the field drifting so the picture ripples like
|
||||
heat haze or water. On the command line it is `-e flow` with
|
||||
`--flow-strength`, `--flow-travel`, `--flow-speed` and the rest.
|
||||
- Sort sweep, a video effect: runs of pixels sorted as Pixel sort sorts
|
||||
them, only within a window of brightness that sweeps from dark to bright
|
||||
and back, so the streaks spread over one part of the picture after
|
||||
another. On the command line it is `-e sweep` with `--sweep-from`,
|
||||
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
|
||||
- Film gate, a video effect: film running through a projector, with grain
|
||||
drawn anew on every frame as Film grain draws it, the picture swaying in
|
||||
the gate, flicker, dust and scratches. On the command line it is
|
||||
`-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`,
|
||||
`--gate-dust` and `--gate-scratches`.
|
||||
- Datamosh, a video effect: blocks of the picture break as Corrupt breaks
|
||||
them, each for a stretch of the clip before it heals, and a displaced
|
||||
block's picture smears along while it shows. On the command line it is
|
||||
`-e datamosh` with `--mosh-rate`, `--mosh-hold`, `--mosh-drift`,
|
||||
`--mosh-speed` and the rest.
|
||||
- Strip roll, a video effect: the canvas cut into strips of the photographs
|
||||
as Scanlines cuts it, and the picture in every strip rolling up or down
|
||||
through its image, or sliding sideways, while the strips stay put. On the
|
||||
command line it is `-e striproll` with `--roll-speed`, `--roll-axis`,
|
||||
`--roll-direction` and the rest.
|
||||
- Focus pull, a video effect: a lens being focused, either the whole
|
||||
picture blurring as Blur blurs it and coming back sharp, or a band of
|
||||
sharpness moving down the picture and back up. On the command line it is
|
||||
`-e focuspull` with `--focus-mode`, `--focus-radius`, `--focus-band` and
|
||||
`--focus-speed`.
|
||||
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
||||
how the saved video is encoded. It is kept in the project, and on the
|
||||
command line it is `--video-quality small|standard|high`. Standard writes
|
||||
videos exactly as before.
|
||||
|
||||
### Changed
|
||||
- Loading images shows its first pass too: the progress window counts the
|
||||
files as their sizes are checked ("Checking sizes: 3 of 40") before it
|
||||
counts the decodes, the headers are read in parallel, and Cancel works
|
||||
during that pass as well. The command line shows a `sizing` line likewise.
|
||||
- The video preview plays sooner: it renders every third frame first and
|
||||
plays that draft, each frame held for three, then fills in the frames
|
||||
between while it plays. The finished preview and the saved video are
|
||||
unchanged.
|
||||
- Save opens in the project's folder, and after the first save it offers the
|
||||
last saved file's name with a number counted on (`city.png`, then
|
||||
`city-1.png`, `city-2.png`), skipping names already taken.
|
||||
- The Add effect menu gives the video effects more room before their list
|
||||
scrolls, now that there are more of them.
|
||||
|
||||
## [0.7.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- Effects are image effects or video effects. The Add effect menu lists them
|
||||
in two sections, each under its own heading, and offers video effects only
|
||||
in a video project. In the chain a video stage has a film icon, and in a
|
||||
still it is dimmed and skipped, like a stage that is switched off.
|
||||
- VHS tracking, the first video effect: the colour planes sway and shake, and
|
||||
a tracking bar rolls down the picture. On the command line it is
|
||||
`-e tracking` with `--tracking-wobble`, `--tracking-jitter`, `--tracking-bar`
|
||||
and the rest.
|
||||
- `--help` lists the video effects under a heading of their own, and
|
||||
`--list-effects` marks them. A video effect given for a still is skipped
|
||||
with a warning.
|
||||
|
||||
### Changed
|
||||
- Channel split is an image effect again. Its motion (Wobble, Jitter, Tracking
|
||||
bar) has moved to VHS tracking, so a 0.6 video project that moved its channel
|
||||
split needs a VHS tracking stage after it. Stills are unchanged.
|
||||
|
||||
## [0.6.1] - 2026-09-26
|
||||
|
||||
### Fixed
|
||||
- Opening a project whose images would not fit in memory no longer gets
|
||||
Effector killed by the system. The images are measured before any is read,
|
||||
and a batch that would not fit is refused with a message saying how much it
|
||||
needs; the images stay in the project, so saving it keeps them.
|
||||
- A canvas too large to render in the free memory is refused the same way,
|
||||
in the interface and on the command line, instead of taking the machine's
|
||||
memory down with it.
|
||||
|
||||
## [0.6.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -73,7 +73,9 @@ GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
|
||||
disk; like a source path it is written relative to the project folder. A `Text`
|
||||
param is free-form text the user types, and a `Color` param is a colour written
|
||||
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
|
||||
shown as a colour button in the interface. Values
|
||||
shown as a colour button in the interface. A `Font` param is a font family
|
||||
named as the machine names it ("DejaVu Serif"), a family-only font chooser in
|
||||
the interface and a plain string everywhere else. Values
|
||||
travel as `ParamValues` (a
|
||||
`map<string, variant<long,double,bool,string>>`), read back with
|
||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
||||
@@ -95,7 +97,8 @@ first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
|
||||
just asks the first stage — a chain led by blur renders with no images loaded at
|
||||
all, and both front-ends gate on that rather than on "are there any images".
|
||||
|
||||
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
|
||||
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect` (or
|
||||
`VideoEffect` for one that moves, see "Video projects"), one
|
||||
line in the registry list in `src/core/registry.cpp`, one line in the
|
||||
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
|
||||
CLI, in the chain menu and as a cascade stage on its own.
|
||||
@@ -172,6 +175,16 @@ 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_`).
|
||||
|
||||
Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so
|
||||
an entry never undoes its own text instead) keep whole snapshots of the
|
||||
project: `captureState` is `currentProject()` plus the `sources_` list itself,
|
||||
so stepping back never decodes again, compared by `projectToJson`. There is
|
||||
nothing to call per edit: `requestRender()` calls `noteChange()`, which commits
|
||||
a step once the controls have been still for `kHistoryPause`; an edit that
|
||||
renders nothing (renaming a style) calls `noteChange()` itself. New and Open
|
||||
start the history over, after the project's images are in
|
||||
(`historyAfterLoad_`).
|
||||
|
||||
### Video projects
|
||||
|
||||
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
|
||||
@@ -181,22 +194,47 @@ like `canvasFormats()` — a preset names a canvas format plus a length and rate
|
||||
and like a format it is the presentation, never the truth: the render goes by
|
||||
duration and fps. Adding a preset is a line there.
|
||||
|
||||
Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default,
|
||||
`fps == 0`, is a still) through `Effect::renderFrame`, which falls back to
|
||||
`render` — an effect that does not move overrides nothing. One that moves
|
||||
overrides `renderFrame` and `animated(values)`, and marks its motion params
|
||||
`.forVideo()`: the interface hides those in a still project (declaratively,
|
||||
like `.styled()`), the CLI still has their flags. A still must render byte for
|
||||
byte what it did before the effect learned to move.
|
||||
Effects are of two kinds, and `Effect::video()` says which. An *image effect*
|
||||
(the default) makes a picture and holds still in a clip; it overrides only
|
||||
`render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
|
||||
and nothing else. `enabledStages(stages, video)` is where the kinds meet the
|
||||
chain: in a still, a video stage is left out exactly like a disabled one,
|
||||
drawing nothing from the rng. So `renderChain` (by `frame.video()`),
|
||||
`chainNeedsSources`, `chainTitle` and `chainSlug` all take whether the render is
|
||||
a video. The interface builds the split from `video()` alone: two sections in
|
||||
the Add effect menu, with the video section insensitive in a still, a
|
||||
`camera-video-symbolic` icon on a video stage's row, and the row dimmed while a
|
||||
still skips it. The CLI groups `--help` the same way and warns when it skips one.
|
||||
|
||||
A video effect subclasses `VideoEffect` (`src/effects/video_effect.hpp`), not
|
||||
`Effect`, and a transform writes only `animate(input, values, const Moment& at)`. The base
|
||||
keeps the rules. It takes the input crop and exactly one value from the shared
|
||||
rng, the same on every frame, and hands out randomness through the `Moment`:
|
||||
`at.steady()` gives the same draws every frame (phases, directions),
|
||||
`at.fresh()` gives new draws per frame (shake, noise). It hands out time as
|
||||
`at.cycle(perSecond)`, rounded through `loopRate` to a whole number of cycles so
|
||||
the clip loops without a jump. `TrackingEffect` (`src/effects/tracking.cpp`,
|
||||
"VHS tracking") is the reference: copy its layout (params, a `Settings` struct,
|
||||
steady draws, time, fresh draws, one parallel pixel pass) for the next one.
|
||||
A video effect that builds its frames out of the sources rather than
|
||||
transforming a crop answers `needsSources()` and writes `compose(sources,
|
||||
width, height, values, at)` instead of `animate`: the base then takes no crop,
|
||||
only the seed, and like an image effect it sees the photographs when it leads
|
||||
the chain and the stage before it otherwise. `TileSwapEffect`
|
||||
(`src/effects/tileswap.cpp`, "Tile swap") is the reference for that kind; it
|
||||
shares `Tile`/`planSpans`/`paintTiles` with the tiles effect through
|
||||
`src/effects/tile_plan.hpp`.
|
||||
Registering it is the same one line in `registry.cpp`, after the comment that
|
||||
starts the video effects. There is no per-param video flag: motion is a video
|
||||
effect of its own, never extra params on an image effect.
|
||||
|
||||
`renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from
|
||||
`Rng(seed)`, so a stage that does not move draws the same values every frame
|
||||
and holds still. A moving effect must therefore leave the shared rng in the
|
||||
same state on every frame — the channel split draws its per-frame shake from a
|
||||
generator of its own, seeded by one draw — or everything after it would flicker.
|
||||
The stages before the first `animated()` one are rendered once and their image
|
||||
and rng state reused; the `video` tests assert that frame k is exactly
|
||||
`renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output.
|
||||
`Rng(seed)`, so an image effect draws the same values every frame and holds
|
||||
still, and because `VideoEffect` leaves the shared rng in one state, the stages
|
||||
after a video effect hold still too. The stages before the first video effect
|
||||
are rendered once and their image and rng state reused. The `video` tests
|
||||
assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
|
||||
shortcut can never change output.
|
||||
The determinism contract covers the frames; the encoded file is whatever the
|
||||
machine's ffmpeg makes of them.
|
||||
|
||||
@@ -261,6 +299,21 @@ then copies pixels in parallel.
|
||||
copy-on-write via `mutateSources()`, so a render in flight can never race with
|
||||
an add/remove.
|
||||
|
||||
### Memory is checked before it is taken
|
||||
|
||||
Running out of memory does not fail cleanly on Linux: the kernel's OOM killer
|
||||
ends the program (and often the terminal or session it runs in), leaving no
|
||||
core dump. So bulk allocations are sized first and refused with an
|
||||
`OutOfMemoryError` (`src/core/memory.hpp`) when they would take more than three
|
||||
quarters of `availableMemory()` — the system's MemAvailable, or less under a
|
||||
cgroup `memory.max`. `loadImages` sizes the whole batch from the file headers
|
||||
(`imageFilePixels`) and refuses it before decoding anything, and holds the
|
||||
decodes it runs at once to what is left; `renderChain` and `renderFrames`
|
||||
refuse a canvas at 24 bytes a pixel (13 measured, plus the interface's copy),
|
||||
plus any picture a `Path` param loads. The interface keeps a refused batch's
|
||||
paths in `refusedSources_`, so saving the project never drops them; the tests
|
||||
fake a small machine with `limitAvailableMemory`.
|
||||
|
||||
### Image buffer
|
||||
|
||||
`Image` is tightly packed 8-bit RGB with stride exactly `width * 3` — effects
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(effector VERSION 0.6.0 LANGUAGES CXX)
|
||||
project(effector VERSION 0.8.1 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -35,6 +35,7 @@ add_library(effector_core STATIC
|
||||
src/core/chain.cpp
|
||||
src/core/effect.cpp
|
||||
src/core/image.cpp
|
||||
src/core/memory.cpp
|
||||
src/core/params.cpp
|
||||
src/core/project.cpp
|
||||
src/core/registry.cpp
|
||||
@@ -45,7 +46,11 @@ add_library(effector_core STATIC
|
||||
src/effects/blur.cpp
|
||||
src/effects/channelsplit.cpp
|
||||
src/effects/corrupt.cpp
|
||||
src/effects/datamosh.cpp
|
||||
src/effects/feedback.cpp
|
||||
src/effects/filmgate.cpp
|
||||
src/effects/focuspull.cpp
|
||||
src/effects/flow.cpp
|
||||
src/effects/grain.cpp
|
||||
src/effects/monochrome.cpp
|
||||
src/effects/overlay.cpp
|
||||
@@ -53,8 +58,14 @@ add_library(effector_core STATIC
|
||||
src/effects/scanlines.cpp
|
||||
src/effects/shatter.cpp
|
||||
src/effects/slitshift.cpp
|
||||
src/effects/striproll.cpp
|
||||
src/effects/sweep.cpp
|
||||
src/effects/text.cpp
|
||||
src/effects/tiles.cpp
|
||||
src/effects/tileswap.cpp
|
||||
src/effects/tracking.cpp
|
||||
src/effects/tunnel.cpp
|
||||
src/effects/video_effect.cpp
|
||||
src/effects/warp.cpp
|
||||
)
|
||||
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
|
||||
@@ -73,18 +73,23 @@ installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
|
||||
palette GIF.
|
||||
|
||||
```bash
|
||||
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 photo.jpg
|
||||
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e channelsplit photo.jpg
|
||||
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -e tracking --seed 1 photo.jpg
|
||||
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e tracking --tracking-bar 40 photo.jpg
|
||||
```
|
||||
|
||||
`--duration SECONDS` and `--fps N` make any run a video; a preset fills in
|
||||
whatever was not given alongside it. Effects that move over time are video
|
||||
effects; for now that is **Channel split**, which in a video sways its
|
||||
channels, shakes them from frame to frame and rolls a tracking bar down the
|
||||
picture, like a worn tape on an old tube. Its motion params only show in a
|
||||
video project, and a still renders exactly as before. Every frame starts from
|
||||
the same seed, so stages that do not move hold still while one that does
|
||||
moves, and a clip renders frame for frame the same again.
|
||||
whatever was not given alongside it.
|
||||
|
||||
Effects come in two kinds. **Image effects** make a picture and hold still in a
|
||||
clip. **Video effects** are motion: the Add effect menu lists them under a
|
||||
heading of their own, and they can only be added once the project is a video.
|
||||
In a still project a video stage is marked as skipped and the render leaves it
|
||||
out, as it would a stage that is switched off. For now there is one video
|
||||
effect, **VHS tracking**: the colour planes sway and shake, and a tracking bar
|
||||
rolls down the picture, like a worn tape on an old tube. Put it after the
|
||||
image effects it should shake. Every frame starts from the same seed, so the
|
||||
image effects hold still while a video effect moves them, and a clip renders
|
||||
frame for frame the same again.
|
||||
|
||||
Any option can also be passed when launching the interface; it becomes the
|
||||
starting state (images, canvas size, effect settings, seed).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# 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.6.0
|
||||
pkgver=0.8.1
|
||||
pkgrel=1
|
||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
+31
-9
@@ -133,6 +133,12 @@ Options parseOptions(int argc, char** argv) {
|
||||
}
|
||||
options.video.enabled = true;
|
||||
options.video.preset = value;
|
||||
} else if (arg == "--video-quality") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
if (videoQualityByKey(value) == nullptr) {
|
||||
throw EffectError("unknown video quality '" + value + "', see --help");
|
||||
}
|
||||
options.video.quality = value;
|
||||
} else if (arg == "--disable") {
|
||||
const std::string name = requireValue(argc, argv, i, arg);
|
||||
effectByName(name); // validate now, exactly as -e does
|
||||
@@ -176,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
|
||||
}
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -241,6 +248,8 @@ std::string usage(const char* programName) {
|
||||
<< " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
|
||||
<< " (default: " << kDefaultFps << ")\n"
|
||||
<< " --video-preset NAME a video's canvas and length in one (see below)\n"
|
||||
<< " --video-quality NAME how a video file is encoded (default: "
|
||||
<< kDefaultVideoQuality << ", see below)\n"
|
||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||
<< " --seed N random seed (default: random)\n"
|
||||
<< " --list-effects list available effects and exit\n"
|
||||
@@ -263,16 +272,29 @@ std::string usage(const char* programName) {
|
||||
if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' ');
|
||||
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
|
||||
}
|
||||
out << "\nVideo qualities (--video-quality):\n";
|
||||
for (const auto& quality : videoQualities()) {
|
||||
out << " " << quality.key;
|
||||
if (quality.key.size() < 20) out << std::string(20 - quality.key.size(), ' ');
|
||||
out << " " << quality.label << ", " << quality.description << "\n";
|
||||
}
|
||||
|
||||
for (const auto& effect : effects()) {
|
||||
out << "\n" << effect->title() << " options:\n";
|
||||
for (const auto& spec : effect->params()) {
|
||||
std::string flag = spec.flag();
|
||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||
out << " " << flag;
|
||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
||||
out << " " << (spec.help.empty() ? spec.label : spec.help)
|
||||
<< (spec.videoOnly ? " (video only)" : "") << "\n";
|
||||
// Image effects first, then the video effects under a heading of their
|
||||
// own: those run only in a clip.
|
||||
for (const bool video : {false, true}) {
|
||||
if (video) {
|
||||
out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
|
||||
}
|
||||
for (const auto& effect : effects()) {
|
||||
if (effect->video() != video) continue;
|
||||
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
|
||||
for (const auto& spec : effect->params()) {
|
||||
std::string flag = spec.flag();
|
||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||
out << " " << flag;
|
||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
||||
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
|
||||
+76
-33
@@ -2,13 +2,17 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/memory.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::vector<const ChainStage*> running;
|
||||
running.reserve(stages.size());
|
||||
for (const auto& stage : stages) {
|
||||
if (stage.enabled && stage.effect) running.push_back(&stage);
|
||||
if (stage.enabled && stage.effect && (video || !stage.effect->video())) {
|
||||
running.push_back(&stage);
|
||||
}
|
||||
}
|
||||
return running;
|
||||
}
|
||||
@@ -34,7 +38,7 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
||||
namespace {
|
||||
|
||||
// The stages that run, checked: throws for a chain with nothing to render.
|
||||
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages) {
|
||||
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
|
||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||
@@ -42,12 +46,42 @@ std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stag
|
||||
|
||||
// A disabled stage never reaches the render at all: it is not handed an
|
||||
// image and draws nothing from the shared rng, so switching one off gives
|
||||
// back exactly the picture the chain made before it was added.
|
||||
auto running = enabledStages(stages);
|
||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
||||
// back exactly the picture the chain made before it was added. A video
|
||||
// effect in a still is left out the same way.
|
||||
auto running = enabledStages(stages, video);
|
||||
if (running.empty()) {
|
||||
const bool videoLeft = std::any_of(stages.begin(), stages.end(), [](const ChainStage& stage) {
|
||||
return stage.enabled && stage.effect->video();
|
||||
});
|
||||
throw EffectError(videoLeft ? "Video effects only run in a video, and the chain has "
|
||||
"no image effect switched on."
|
||||
: "Every effect in the chain is disabled.");
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
// Throws OutOfMemoryError for a render that would not fit. A render holds a
|
||||
// few canvases at once -- the stage before, the one being drawn, the input
|
||||
// a transform copies -- and the file it is saved to; 13 bytes a pixel is
|
||||
// what the heaviest chains were measured at, and the interface keeps one
|
||||
// more copy to show, so 24 leaves room. A picture a stage loads itself (an
|
||||
// overlay, a text box's picture) comes on top, with its alpha and pixbuf.
|
||||
void ensureRenderFits(const std::vector<const ChainStage*>& running, int width, int height,
|
||||
const StyleSet& styles) {
|
||||
std::size_t needed = static_cast<std::size_t>(std::max(width, 0)) *
|
||||
static_cast<std::size_t>(std::max(height, 0)) * 24;
|
||||
for (const auto* stage : running) {
|
||||
const auto values = stageValues(*stage, styles);
|
||||
for (const auto& spec : stage->effect->params()) {
|
||||
if (spec.type != ParamSpec::Type::Path) continue;
|
||||
const std::string path = paramText(values, spec.key, "");
|
||||
if (!path.empty()) needed += imageFilePixels(path) * 8;
|
||||
}
|
||||
}
|
||||
ensureMemory(needed, "Rendering a " + std::to_string(width) + " × " + std::to_string(height) +
|
||||
" canvas");
|
||||
}
|
||||
|
||||
// Runs running[first, last) on `image`, which holds the sources' place for
|
||||
// the first stage of the chain and the previous stage's output after it.
|
||||
Image runStages(const std::vector<const ChainStage*>& running, std::size_t first,
|
||||
@@ -75,69 +109,78 @@ Image runStages(const std::vector<const ChainStage*>& running, std::size_t first
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng, const StyleSet& styles,
|
||||
const FrameTime& frame) {
|
||||
const auto running = runningStages(stages);
|
||||
const auto running = runningStages(stages, frame.video());
|
||||
ensureRenderFits(running, width, height, styles);
|
||||
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
|
||||
frame);
|
||||
}
|
||||
|
||||
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles) {
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
if (stage->effect->animated(stageValues(*stage, styles))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||
const VideoSettings& video,
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame) {
|
||||
const auto running = runningStages(stages);
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||
const std::vector<int>& order) {
|
||||
const auto running = runningStages(stages, true);
|
||||
ensureRenderFits(running, width, height, styles);
|
||||
const int count = video.frameCount();
|
||||
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
|
||||
|
||||
// Everything before the first stage that moves is the same on every
|
||||
// frame -- the same seed, the same values, no time -- so it is rendered
|
||||
// once, as frame 0, and picked up from there. The rng is copied along
|
||||
// with the picture, which is what makes the shortcut exact.
|
||||
// Everything before the first video effect is the same on every frame --
|
||||
// the same seed, the same values, no time -- so it is rendered once, as
|
||||
// frame 0, and picked up from there. The rng is copied along with the
|
||||
// picture, which is what makes the shortcut exact.
|
||||
std::size_t still = 0;
|
||||
while (still < running.size() &&
|
||||
!running[still]->effect->animated(stageValues(*running[still], styles))) {
|
||||
++still;
|
||||
}
|
||||
while (still < running.size() && !running[still]->effect->video()) ++still;
|
||||
Rng prefixRng(seed);
|
||||
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
|
||||
styles, FrameTime{0, count, fps});
|
||||
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const int total = order.empty() ? count : static_cast<int>(order.size());
|
||||
int handed = 0;
|
||||
for (int position = 0; position < total; ++position) {
|
||||
const int index = order.empty() ? position : order[static_cast<std::size_t>(position)];
|
||||
if (index < 0 || index >= count) continue;
|
||||
const FrameTime frame{index, count, fps};
|
||||
Rng rng = prefixRng;
|
||||
Image image = still == running.size()
|
||||
? prefix
|
||||
: runStages(running, still, running.size(), sources, prefix, width,
|
||||
height, rng, styles, frame);
|
||||
if (!onFrame(index, std::move(image))) return index + 1;
|
||||
++handed;
|
||||
if (!onFrame(index, std::move(image))) break;
|
||||
}
|
||||
return count;
|
||||
return handed;
|
||||
}
|
||||
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||
const auto running = enabledStages(stages);
|
||||
std::vector<int> draftFirstOrder(int count, int step) {
|
||||
step = std::max(1, step);
|
||||
std::vector<int> order;
|
||||
order.reserve(static_cast<std::size_t>(std::max(0, count)));
|
||||
for (int index = 0; index < count; index += step) order.push_back(index);
|
||||
for (int index = 0; index < count; ++index) {
|
||||
if (index % step != 0) order.push_back(index);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
|
||||
const auto running = enabledStages(stages, video);
|
||||
if (running.empty()) return false;
|
||||
return running.front()->effect->needsSources();
|
||||
}
|
||||
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::string text;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
for (const auto* stage : enabledStages(stages, video)) {
|
||||
if (!text.empty()) text += " → ";
|
||||
text += stage->effect->title();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::string text;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
for (const auto* stage : enabledStages(stages, video)) {
|
||||
if (!text.empty()) text += "-";
|
||||
text += stage->effect->name();
|
||||
}
|
||||
|
||||
+25
-16
@@ -40,11 +40,12 @@ struct ChainStage {
|
||||
// that style carries. The one place a style is resolved.
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||
|
||||
// The stages that actually run, in order, as pointers into `stages`. A
|
||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
||||
// renders exactly as it would if that stage had never been added -- this is
|
||||
// the one place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
||||
// The stages that actually run, in order, as pointers into `stages`: those
|
||||
// switched on, and of those the video effects only in a video. A stage left
|
||||
// out draws nothing from the rng and is handed no image, so a chain renders
|
||||
// exactly as it would if that stage had never been added -- this is the one
|
||||
// place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// Renders the enabled stages in order. The first one sees the loaded sources;
|
||||
// every later one sees the previous stage's output as its only source, so blur
|
||||
@@ -55,38 +56,46 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
||||
// `styles` is the project's pool; a chain built without one -- the command
|
||||
// line's -- passes none and renders from the stages alone.
|
||||
//
|
||||
// `frame` says where in a video the render sits, a still by default; every
|
||||
// stage is handed it, and only one that moves makes anything of it.
|
||||
// `frame` says where in a video the render sits, a still by default, in
|
||||
// which the video effects are skipped; every stage that runs is handed it,
|
||||
// and only a video effect makes anything of it.
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng, const StyleSet& styles = {},
|
||||
const FrameTime& frame = {});
|
||||
|
||||
// Whether any stage that runs moves over time with the values it runs with.
|
||||
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles = {});
|
||||
|
||||
// Renders every frame of a video, in order, handing each to `onFrame`; it
|
||||
// returning false stops the render there. Frame k is exactly what
|
||||
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} --
|
||||
// every frame starts from the same seed, so a stage that does not move
|
||||
// renders the same picture throughout rather than reshuffling on every
|
||||
// frame. The stages before the first one that moves are rendered once, and
|
||||
// frame. The stages before the first video effect are rendered once, and
|
||||
// their picture and the rng state they leave are reused for every frame.
|
||||
// Returns the number of frames handed out.
|
||||
//
|
||||
// `order` lists the frame indices to render, in the order to render them;
|
||||
// empty means every frame from the first. Each frame is the same whichever
|
||||
// order it comes in, so a preview can render a draft first (draftFirstOrder).
|
||||
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||
const VideoSettings& video,
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame);
|
||||
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||
const std::vector<int>& order = {});
|
||||
|
||||
// Every frame of a clip of `count`, draft first: every `step`th frame from
|
||||
// the first, which held for `step` frames each plays the whole clip roughly,
|
||||
// and then the frames between them, in order.
|
||||
std::vector<int> draftFirstOrder(int count, int step);
|
||||
|
||||
// Whether the chain has to be given source images. Only the first stage that
|
||||
// runs ever sees them — every later one works on the stage before it — so this
|
||||
// is simply whether that effect declares that it needs them.
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||
// what was rendered, so a disabled stage is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||
// what was rendered, so a stage that did not run is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// "scanlines-blur", for file names.
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages);
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+12
-11
@@ -58,21 +58,22 @@ public:
|
||||
return title();
|
||||
}
|
||||
|
||||
// Whether a stage with these values changes from one frame of a video to
|
||||
// the next. A stage that does not is rendered once for the whole clip.
|
||||
virtual bool animated(const ParamValues& values) const {
|
||||
(void)values;
|
||||
return false;
|
||||
}
|
||||
// Whether this is a video effect rather than an image effect. An image
|
||||
// effect makes a picture and holds still in a clip; a video effect is
|
||||
// motion and nothing else, so it is offered only in a video project and a
|
||||
// still skips it whole, as it skips a stage that is switched off. Video
|
||||
// effects derive from VideoEffect (effects/video_effect.hpp), which
|
||||
// answers true here and keeps the rules a moving stage has to follow.
|
||||
virtual bool video() const { return false; }
|
||||
|
||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const = 0;
|
||||
|
||||
// One frame of a video, or a still when `frame` says so. An effect that
|
||||
// does not move needs only render(), which this falls back to. One that
|
||||
// does must leave `rng` in the same state on every frame -- a frame's own
|
||||
// randomness comes from a generator of its own -- so the stages after it
|
||||
// stay put while it moves.
|
||||
// One frame of a video, or a still when `frame` says so. An image effect
|
||||
// needs only render(), which this falls back to; a video effect renders
|
||||
// here, and must leave `rng` in the same state on every frame -- a
|
||||
// frame's own randomness comes from a generator of its own -- so the
|
||||
// stages after it stay put while it moves.
|
||||
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const {
|
||||
(void)frame;
|
||||
|
||||
+78
-33
@@ -11,6 +11,8 @@
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
#include "core/memory.hpp"
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
|
||||
@@ -152,48 +154,91 @@ void Image::save(const std::string& path) const {
|
||||
if (saved == FALSE) throw std::runtime_error(takeError(error));
|
||||
}
|
||||
|
||||
std::size_t imageFilePixels(const std::string& path) {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (gdk_pixbuf_get_file_info(path.c_str(), &width, &height) == nullptr) return 0;
|
||||
return static_cast<std::size_t>(std::max(width, 0)) * static_cast<std::size_t>(std::max(height, 0));
|
||||
}
|
||||
|
||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||
std::vector<LoadFailure>& failures,
|
||||
const LoadProgress* progress) {
|
||||
// Decoding is the dominant cost of a headless run, and the files are
|
||||
// independent, so they are decoded in parallel. Threads take the next path
|
||||
// from a shared cursor rather than a fixed slice, so one huge file cannot
|
||||
// stall the files behind it and progress arrives at a steady rate. Slots
|
||||
// keep the result in the order the paths were given.
|
||||
std::vector<Image> slots(paths.size());
|
||||
std::vector<std::string> errors(paths.size());
|
||||
std::atomic<std::size_t> cursor{0};
|
||||
std::atomic<std::size_t> done{0};
|
||||
const auto cancelled = [progress] {
|
||||
return progress != nullptr && progress->cancel != nullptr &&
|
||||
progress->cancel->load(std::memory_order_relaxed);
|
||||
};
|
||||
// Runs `work(i)` for every path on `workers` threads. Threads take the next
|
||||
// path from a shared cursor rather than a fixed slice, so one huge or slow
|
||||
// file cannot stall the files behind it and progress arrives at a steady
|
||||
// rate. A cancel stops every thread before its next path.
|
||||
const auto forEachPath = [&](std::size_t workers, const auto& work) {
|
||||
std::atomic<std::size_t> cursor{0};
|
||||
const auto run = [&] {
|
||||
for (;;) {
|
||||
if (cancelled()) return;
|
||||
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
||||
if (i >= paths.size()) return;
|
||||
work(i);
|
||||
}
|
||||
};
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(workers > 0 ? workers - 1 : 0);
|
||||
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(run);
|
||||
run();
|
||||
for (auto& thread : threads) thread.join();
|
||||
};
|
||||
|
||||
unsigned hardware = std::thread::hardware_concurrency();
|
||||
if (hardware == 0) hardware = 1;
|
||||
const std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
||||
std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
||||
|
||||
const auto decode = [&] {
|
||||
for (;;) {
|
||||
if (progress != nullptr && progress->cancel != nullptr &&
|
||||
progress->cancel->load(std::memory_order_relaxed)) {
|
||||
return;
|
||||
}
|
||||
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
||||
if (i >= paths.size()) return;
|
||||
try {
|
||||
slots[i] = Image::load(paths[i]);
|
||||
} catch (const std::exception& error) {
|
||||
errors[i] = error.what();
|
||||
}
|
||||
if (progress != nullptr && progress->onFile) {
|
||||
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
// What the batch holds once decoded, and what one decode needs on top
|
||||
// while it runs: the pixbuf (with alpha, at worst) and its rotated copy.
|
||||
// Reading a header can mean a round trip to a slow disk or a network
|
||||
// share, so the headers are read in parallel too, and reported.
|
||||
std::vector<std::size_t> pixels(paths.size(), 0);
|
||||
std::atomic<std::size_t> sized{0};
|
||||
forEachPath(workers, [&](std::size_t i) {
|
||||
pixels[i] = imageFilePixels(paths[i]);
|
||||
if (progress != nullptr && progress->onSized) {
|
||||
progress->onSized(sized.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
};
|
||||
});
|
||||
if (cancelled()) return {};
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(workers > 0 ? workers - 1 : 0);
|
||||
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(decode);
|
||||
decode();
|
||||
for (auto& thread : threads) thread.join();
|
||||
std::size_t kept = 0;
|
||||
std::size_t largest = 0;
|
||||
for (const std::size_t count : pixels) {
|
||||
kept += count * 3;
|
||||
largest = std::max(largest, count * 8);
|
||||
}
|
||||
const std::string what = paths.size() == 1
|
||||
? std::string("Loading this image")
|
||||
: "Loading these " + std::to_string(paths.size()) + " images";
|
||||
ensureMemory(kept + largest, what);
|
||||
const std::size_t spare = usableMemory() - std::min(usableMemory(), kept);
|
||||
|
||||
// Decoding is the dominant cost of a headless run, and the files are
|
||||
// independent, so they are decoded in parallel. Slots keep the result in
|
||||
// the order the paths were given.
|
||||
std::vector<Image> slots(paths.size());
|
||||
std::vector<std::string> errors(paths.size());
|
||||
std::atomic<std::size_t> done{0};
|
||||
if (largest > 0) workers = std::clamp<std::size_t>(spare / largest, 1, workers);
|
||||
|
||||
forEachPath(workers, [&](std::size_t i) {
|
||||
try {
|
||||
slots[i] = Image::load(paths[i]);
|
||||
} catch (const std::exception& error) {
|
||||
errors[i] = error.what();
|
||||
}
|
||||
if (progress != nullptr && progress->onFile) {
|
||||
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
});
|
||||
|
||||
std::vector<Image> images;
|
||||
images.reserve(paths.size());
|
||||
|
||||
+14
-2
@@ -61,16 +61,28 @@ struct LoadFailure {
|
||||
};
|
||||
|
||||
// Lets a caller follow a long batch decode and abort it. Both members are
|
||||
// touched from the decoding threads, so `onFile` must be thread-safe and must
|
||||
// not block: the GUI only bumps a counter from it and wakes the main thread.
|
||||
// touched from the decoding threads, so `onSized` and `onFile` must be
|
||||
// thread-safe and must not block: the GUI only bumps a counter from them and
|
||||
// wakes the main thread. `onSized` follows the first pass, which reads every
|
||||
// header to size the batch; `onFile` follows the decodes after it.
|
||||
struct LoadProgress {
|
||||
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onSized;
|
||||
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
|
||||
std::atomic<bool>* cancel = nullptr;
|
||||
};
|
||||
|
||||
// The pixel count of an image file from its header alone, without decoding
|
||||
// it; 0 when the header cannot be read.
|
||||
std::size_t imageFilePixels(const std::string& path);
|
||||
|
||||
// Loads every path, collecting rather than throwing on individual failures.
|
||||
// When `progress->cancel` is set the remaining paths are skipped, so the result
|
||||
// is short and should be discarded rather than used.
|
||||
//
|
||||
// Before decoding anything the whole batch is sized from the file headers,
|
||||
// and a batch that would not fit in the free memory throws OutOfMemoryError
|
||||
// (core/memory.hpp) with nothing loaded; the decodes that run at once are
|
||||
// also held to what is left over.
|
||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||
std::vector<LoadFailure>& failures,
|
||||
const LoadProgress* progress = nullptr);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "core/memory.hpp"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
|
||||
std::atomic<std::size_t> cap{0};
|
||||
|
||||
// MemAvailable from /proc/meminfo, falling back on the free pages; 0 when
|
||||
// neither says.
|
||||
std::size_t systemAvailable() {
|
||||
std::ifstream meminfo("/proc/meminfo");
|
||||
std::string key;
|
||||
std::size_t kilobytes = 0;
|
||||
std::string unit;
|
||||
while (meminfo >> key >> kilobytes >> unit) {
|
||||
if (key == "MemAvailable:") return kilobytes * 1024;
|
||||
}
|
||||
const long pages = sysconf(_SC_AVPHYS_PAGES);
|
||||
const long pageSize = sysconf(_SC_PAGESIZE);
|
||||
if (pages > 0 && pageSize > 0) {
|
||||
return static_cast<std::size_t>(pages) * static_cast<std::size_t>(pageSize);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A number from a cgroup file, or nothing for "max" or a file that is not
|
||||
// there.
|
||||
bool readCount(const std::filesystem::path& file, std::size_t& out) {
|
||||
std::ifstream in(file);
|
||||
std::string word;
|
||||
if (!(in >> word) || word == "max") return false;
|
||||
try {
|
||||
out = static_cast<std::size_t>(std::stoull(word));
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t inactiveFile(const std::filesystem::path& dir) {
|
||||
std::ifstream in(dir / "memory.stat");
|
||||
std::string key;
|
||||
std::size_t value = 0;
|
||||
while (in >> key >> value) {
|
||||
if (key == "inactive_file") return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The room left under the tightest memory.max on the way from this
|
||||
// process's cgroup (v2) up to the root, counting the page cache it could
|
||||
// drop as room; the largest value when nothing caps it.
|
||||
std::size_t cgroupAvailable() {
|
||||
std::size_t room = std::numeric_limits<std::size_t>::max();
|
||||
std::ifstream self("/proc/self/cgroup");
|
||||
std::string line;
|
||||
while (std::getline(self, line)) {
|
||||
if (line.rfind("0::", 0) != 0) continue;
|
||||
// "0::/user.slice/app.slice/x.scope", walked from the scope upwards.
|
||||
for (std::filesystem::path group = line.substr(4); !group.empty();
|
||||
group = group.parent_path()) {
|
||||
const auto dir = std::filesystem::path("/sys/fs/cgroup") / group;
|
||||
std::size_t max = 0;
|
||||
std::size_t current = 0;
|
||||
if (!readCount(dir / "memory.max", max) || !readCount(dir / "memory.current", current)) {
|
||||
continue;
|
||||
}
|
||||
const std::size_t used = current - std::min(current, inactiveFile(dir));
|
||||
room = std::min(room, max - std::min(max, used));
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::size_t availableMemory() {
|
||||
std::size_t available = systemAvailable();
|
||||
const std::size_t room = cgroupAvailable();
|
||||
if (room != std::numeric_limits<std::size_t>::max()) {
|
||||
available = available == 0 ? room : std::min(available, room);
|
||||
}
|
||||
const std::size_t capped = cap.load(std::memory_order_relaxed);
|
||||
if (capped != 0 && (available == 0 || capped < available)) available = capped;
|
||||
return available;
|
||||
}
|
||||
|
||||
void limitAvailableMemory(std::size_t bytes) { cap.store(bytes, std::memory_order_relaxed); }
|
||||
|
||||
std::size_t usableMemory() {
|
||||
const std::size_t available = availableMemory();
|
||||
return available == 0 ? std::numeric_limits<std::size_t>::max() : available / 4 * 3;
|
||||
}
|
||||
|
||||
void ensureMemory(std::size_t needed, const std::string& what) {
|
||||
const std::size_t available = availableMemory();
|
||||
if (available == 0 || needed <= available / 4 * 3) return;
|
||||
throw OutOfMemoryError(what + " needs about " + formatBytes(needed) +
|
||||
" of memory, more than is safe to take with " + formatBytes(available) +
|
||||
" free.");
|
||||
}
|
||||
|
||||
std::string formatBytes(std::size_t bytes) {
|
||||
char text[32];
|
||||
const double gb = static_cast<double>(bytes) / (1024.0 * 1024.0 * 1024.0);
|
||||
if (gb >= 1.0) {
|
||||
std::snprintf(text, sizeof text, "%.1f GB", gb);
|
||||
} else {
|
||||
std::snprintf(text, sizeof text, "%.0f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Thrown instead of starting work that would not fit in memory. Decoding a
|
||||
// batch of photographs or rendering a huge canvas past what the machine has
|
||||
// free does not fail cleanly: the kernel's OOM killer ends the program, and
|
||||
// often others with it.
|
||||
class OutOfMemoryError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// The bytes this process could still allocate without swapping: the
|
||||
// system's MemAvailable, or less when a cgroup (a systemd unit, a container)
|
||||
// caps the process below it. Zero when neither can be read.
|
||||
std::size_t availableMemory();
|
||||
|
||||
// Caps what availableMemory() reports, for the tests; 0 lifts the cap.
|
||||
void limitAvailableMemory(std::size_t bytes);
|
||||
|
||||
// Throws OutOfMemoryError when `needed` more bytes would take more than three
|
||||
// quarters of availableMemory() -- the rest is left to the desktop and to
|
||||
// whatever else the program holds. `what` names the work in the message.
|
||||
// Does nothing when the free memory cannot be read.
|
||||
void ensureMemory(std::size_t needed, const std::string& what);
|
||||
|
||||
// The bytes ensureMemory() would let through right now; the largest value
|
||||
// when the free memory cannot be read.
|
||||
std::size_t usableMemory();
|
||||
|
||||
// "4.6 GB", "380 MB".
|
||||
std::string formatBytes(std::size_t bytes);
|
||||
|
||||
} // namespace effector
|
||||
+12
-6
@@ -16,12 +16,6 @@ ParamSpec ParamSpec::styled() const {
|
||||
return copy;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::forVideo() const {
|
||||
ParamSpec copy = *this;
|
||||
copy.videoOnly = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help, long step) {
|
||||
ParamSpec spec;
|
||||
@@ -106,6 +100,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeFont(std::string key, std::string label, std::string defaultFamily,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Font;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultFamily);
|
||||
return spec;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
@@ -127,6 +132,7 @@ ParamValue paramDefault(const ParamSpec& spec) {
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Color:
|
||||
case ParamSpec::Type::Font:
|
||||
break;
|
||||
}
|
||||
return spec.defaultChoice;
|
||||
|
||||
+6
-8
@@ -11,7 +11,7 @@ namespace effector {
|
||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
||||
// and the CLI turns them into flags, so neither knows about any effect.
|
||||
struct ParamSpec {
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color, Font };
|
||||
|
||||
Type type = Type::Int;
|
||||
std::string key;
|
||||
@@ -27,10 +27,6 @@ struct ParamSpec {
|
||||
// Whether a named style carries this param rather than the stage it sits
|
||||
// on: see core/style.hpp.
|
||||
bool inStyle = false;
|
||||
// Whether the param only means something in a video project -- how a
|
||||
// stage moves over time. The interface leaves it out of a still project,
|
||||
// where it would change nothing.
|
||||
bool videoOnly = false;
|
||||
|
||||
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||
|
||||
@@ -38,9 +34,6 @@ struct ParamSpec {
|
||||
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
||||
// signatures they have.
|
||||
ParamSpec styled() const;
|
||||
// A copy of this spec marked as mattering only in a video project, read
|
||||
// the same way: makeInt("split_jitter", ...).forVideo().
|
||||
ParamSpec forVideo() const;
|
||||
|
||||
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help = {}, long step = 1);
|
||||
@@ -63,6 +56,11 @@ struct ParamSpec {
|
||||
// spelling as a flag value.
|
||||
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
|
||||
std::string help = {});
|
||||
// A font family as it is named on this machine ("Sans", "DejaVu Serif"):
|
||||
// a font chooser in the interface, a flag taking any string on the
|
||||
// command line. A family the machine lacks is kept as written.
|
||||
static ParamSpec makeFont(std::string key, std::string label, std::string defaultFamily,
|
||||
std::string help = {});
|
||||
};
|
||||
|
||||
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||
|
||||
@@ -366,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -412,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -462,6 +464,11 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
||||
if (const auto* preset = videoPresetByKey(project.video.preset)) {
|
||||
out << ", \"preset\": " << quote(preset->key);
|
||||
}
|
||||
// Written only when it is not the standard one.
|
||||
if (const auto* quality = videoQualityByKey(project.video.quality);
|
||||
quality != nullptr && quality->key != kDefaultVideoQuality) {
|
||||
out << ", \"quality\": " << quote(quality->key);
|
||||
}
|
||||
out << "},\n";
|
||||
}
|
||||
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||
@@ -548,6 +555,13 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||
name->kind == Json::Kind::String) {
|
||||
if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key;
|
||||
}
|
||||
// An unknown quality is the standard one, as it is everywhere else.
|
||||
if (const auto* name = video->member("quality"); name != nullptr &&
|
||||
name->kind == Json::Kind::String) {
|
||||
if (const auto* quality = videoQualityByKey(name->text)) {
|
||||
project.video.quality = quality->key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto* seed = root.member("seed")) {
|
||||
@@ -660,4 +674,22 @@ Project loadProject(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string nextExportPath(const std::string& previous) {
|
||||
const std::filesystem::path path(previous);
|
||||
std::string stem = path.stem().string();
|
||||
long count = 0;
|
||||
const auto dash = stem.rfind('-');
|
||||
if (dash != std::string::npos && dash + 1 < stem.size() && stem.size() - dash <= 10 &&
|
||||
stem.find_first_not_of("0123456789", dash + 1) == std::string::npos) {
|
||||
count = std::strtol(stem.c_str() + dash + 1, nullptr, 10);
|
||||
stem.resize(dash);
|
||||
}
|
||||
std::filesystem::path next;
|
||||
std::error_code error;
|
||||
do {
|
||||
next = path.parent_path() / (stem + "-" + std::to_string(++count) + path.extension().string());
|
||||
} while (std::filesystem::exists(next, error));
|
||||
return next.string();
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -54,4 +54,9 @@ Project projectFromJson(const std::string& text, const std::string& directory =
|
||||
void saveProject(const std::string& path, const Project& project);
|
||||
Project loadProject(const std::string& path);
|
||||
|
||||
// The name to offer for the next export after `previous`: its stem with the
|
||||
// trailing `-N` counted on (`city.png` → `city-1.png`, `city-4.png` →
|
||||
// `city-5.png`), skipping any that already exist in its folder.
|
||||
std::string nextExportPath(const std::string& previous);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/channelsplit.hpp"
|
||||
#include "effects/corrupt.hpp"
|
||||
#include "effects/datamosh.hpp"
|
||||
#include "effects/feedback.hpp"
|
||||
#include "effects/filmgate.hpp"
|
||||
#include "effects/focuspull.hpp"
|
||||
#include "effects/flow.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
@@ -12,8 +16,13 @@
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/shatter.hpp"
|
||||
#include "effects/slitshift.hpp"
|
||||
#include "effects/striproll.hpp"
|
||||
#include "effects/sweep.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/tileswap.hpp"
|
||||
#include "effects/tracking.hpp"
|
||||
#include "effects/tunnel.hpp"
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -36,6 +45,16 @@ const std::vector<EffectPtr>& effects() {
|
||||
std::make_shared<WarpEffect>(),
|
||||
std::make_shared<BitCrushEffect>(),
|
||||
std::make_shared<FeedbackEffect>(),
|
||||
// Video effects, offered only in a video project.
|
||||
std::make_shared<TrackingEffect>(),
|
||||
std::make_shared<TileSwapEffect>(),
|
||||
std::make_shared<TunnelEffect>(),
|
||||
std::make_shared<FlowEffect>(),
|
||||
std::make_shared<SweepEffect>(),
|
||||
std::make_shared<FilmGateEffect>(),
|
||||
std::make_shared<DatamoshEffect>(),
|
||||
std::make_shared<StripRollEffect>(),
|
||||
std::make_shared<FocusPullEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
+35
-4
@@ -51,6 +51,31 @@ const VideoPreset* videoPresetByKey(const std::string& key) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::vector<VideoQuality>& videoQualities() {
|
||||
// Standard is what every video was written with before there was a
|
||||
// choice: CRF 18 is close to what the eye can tell from the frames.
|
||||
static const std::vector<VideoQuality> qualities = {
|
||||
{"small", "Small file", "smaller and quicker to share, with visible softening", 26, "fast",
|
||||
40, 128},
|
||||
{kDefaultVideoQuality, "Standard", "close to the rendered frames at a moderate size", 18,
|
||||
"medium", 32, 256},
|
||||
{"high", "High", "as faithful as it gets, larger and slower to write", 12, "slow", 24, 256},
|
||||
};
|
||||
return qualities;
|
||||
}
|
||||
|
||||
const VideoQuality* videoQualityByKey(const std::string& key) {
|
||||
for (const auto& quality : videoQualities()) {
|
||||
if (quality.key == key) return &quality;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const VideoQuality& videoQualityOrDefault(const std::string& key) {
|
||||
if (const auto* quality = videoQualityByKey(key)) return *quality;
|
||||
return *videoQualityByKey(kDefaultVideoQuality);
|
||||
}
|
||||
|
||||
// In the C locale whatever the user's is, for the same reason as the
|
||||
// millimetres of a paper size: "7.5 s", never "7,5 s".
|
||||
std::string formatSeconds(double seconds) {
|
||||
@@ -106,7 +131,8 @@ bool ffmpegAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps)
|
||||
VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps,
|
||||
const std::string& quality)
|
||||
: path_(path), width_(width), height_(height) {
|
||||
const std::string muxer = muxerFor(lowerExtension(path));
|
||||
if (muxer.empty()) {
|
||||
@@ -120,6 +146,7 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
|
||||
static std::once_flag ignorePipe;
|
||||
std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); });
|
||||
|
||||
const VideoQuality& squeeze = videoQualityOrDefault(quality);
|
||||
std::vector<std::string> args = {
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
@@ -129,16 +156,20 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
|
||||
};
|
||||
if (muxer == "gif") {
|
||||
// One palette made for the whole clip, rather than the fixed web one.
|
||||
args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen[p];[a][p]paletteuse",
|
||||
args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen=max_colors=" +
|
||||
std::to_string(squeeze.gifColors) +
|
||||
"[p];[a][p]paletteuse",
|
||||
"-loop", "0"});
|
||||
} else {
|
||||
// 4:2:0 wants even sides; a pixel of padding beats refusing the canvas.
|
||||
args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"});
|
||||
if (muxer == "webm") {
|
||||
args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0",
|
||||
args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf),
|
||||
"-b:v", "0",
|
||||
"-row-mt", "1"});
|
||||
} else {
|
||||
args.insert(args.end(), {"-c:v", "libx264", "-preset", "medium", "-crf", "18"});
|
||||
args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf",
|
||||
std::to_string(squeeze.h264Crf)});
|
||||
}
|
||||
args.insert(args.end(), {"-pix_fmt", "yuv420p"});
|
||||
if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"});
|
||||
|
||||
+32
-2
@@ -31,6 +31,10 @@ struct VideoSettings {
|
||||
// canvas format it is the presentation of the numbers, never the truth:
|
||||
// the render goes by duration and fps.
|
||||
std::string preset;
|
||||
// How hard the file is squeezed: a key of videoQualities(), empty or
|
||||
// unknown for the standard one. Only the encoded file hears of it; the
|
||||
// frames are the same at every quality.
|
||||
std::string quality;
|
||||
|
||||
// The frames the clip has: the length at the rate, never fewer than one.
|
||||
int frameCount() const;
|
||||
@@ -55,6 +59,30 @@ const VideoPreset* videoPresetByKey(const std::string& key);
|
||||
// "1080 × 1920 px · 15 s", for the dropdown and for --help.
|
||||
std::string videoPresetDetail(const VideoPreset& preset);
|
||||
|
||||
// How a video file is encoded, from small and quick to large and faithful.
|
||||
// The numbers go to ffmpeg by the codec the file's extension picks.
|
||||
struct VideoQuality {
|
||||
std::string key; // written in a project and given to --video-quality
|
||||
std::string label; // shown in the interface
|
||||
std::string description; // for the tooltip and --help
|
||||
int h264Crf = 18; // .mp4, .mov, .mkv: lower is better and larger
|
||||
std::string h264Preset; // x264's speed preset: slower packs tighter
|
||||
int vp9Crf = 32; // .webm
|
||||
int gifColors = 256; // .gif palette size
|
||||
};
|
||||
|
||||
// In the order they are offered, smallest first.
|
||||
const std::vector<VideoQuality>& videoQualities();
|
||||
|
||||
// The standard quality, which an empty or unknown key stands for.
|
||||
inline constexpr const char* kDefaultVideoQuality = "standard";
|
||||
|
||||
// Null when the key names no quality.
|
||||
const VideoQuality* videoQualityByKey(const std::string& key);
|
||||
|
||||
// The quality a key stands for: the standard one for an empty or unknown key.
|
||||
const VideoQuality& videoQualityOrDefault(const std::string& key);
|
||||
|
||||
// "15 s", "7.5 s": a length as the interface and --help show it.
|
||||
std::string formatSeconds(double seconds);
|
||||
|
||||
@@ -68,7 +96,7 @@ bool ffmpegAvailable();
|
||||
// program. Run as a plain argv rather than through a shell, so a file name is
|
||||
// never read as anything but a file name. The container and codec follow the
|
||||
// extension: H.264 for .mp4, .mov and .mkv, VP9 for .webm, a palette GIF for
|
||||
// .gif. The encoded bytes are ffmpeg's business; the frames going in are the
|
||||
// .gif, squeezed as the quality says. The encoded bytes are ffmpeg's business; the frames going in are the
|
||||
// ones the seed decides.
|
||||
//
|
||||
// Throws EffectError when ffmpeg is missing, fails, or quits early, with what
|
||||
@@ -76,7 +104,9 @@ bool ffmpegAvailable();
|
||||
// itself on destruction, so a half-written file is never left behind.
|
||||
class VideoWriter {
|
||||
public:
|
||||
VideoWriter(const std::string& path, int width, int height, int fps);
|
||||
// The quality is a key of videoQualities(); empty means the standard one.
|
||||
VideoWriter(const std::string& path, int width, int height, int fps,
|
||||
const std::string& quality = {});
|
||||
~VideoWriter();
|
||||
VideoWriter(const VideoWriter&) = delete;
|
||||
VideoWriter& operator=(const VideoWriter&) = delete;
|
||||
|
||||
+2
-64
@@ -1,10 +1,9 @@
|
||||
#include "effects/blur.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/box_blur.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -23,57 +22,6 @@ const std::vector<ParamSpec>& BlurEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||
// are; the window is clamped at both ends.
|
||||
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
int sum[3] = {0, 0, 0};
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] = in[c] * (radius + 1);
|
||||
for (int i = 1; i <= radius; ++i) {
|
||||
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[static_cast<std::size_t>(i) * stride + c] =
|
||||
static_cast<std::uint8_t>(sum[c] / window);
|
||||
}
|
||||
const int add = std::min(i + radius + 1, count - 1);
|
||||
const int drop = std::max(i - radius, 0);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||
in[static_cast<std::size_t>(drop) * stride + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||
radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void blurVertical(const Image& src, Image& dst, int radius) {
|
||||
const std::size_t stride = src.stride();
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
const std::size_t offset = x * 3;
|
||||
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -94,17 +42,7 @@ Image BlurEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
}
|
||||
|
||||
Image scratch(width, height);
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
if (direction != "vertical") {
|
||||
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
if (direction != "horizontal") {
|
||||
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
}
|
||||
boxBlur(canvas, radius, passes, direction);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||
// are; the window is clamped at both ends.
|
||||
inline void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride,
|
||||
int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
int sum[3] = {0, 0, 0};
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] = in[c] * (radius + 1);
|
||||
for (int i = 1; i <= radius; ++i) {
|
||||
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[static_cast<std::size_t>(i) * stride + c] =
|
||||
static_cast<std::uint8_t>(sum[c] / window);
|
||||
}
|
||||
const int add = std::min(i + radius + 1, count - 1);
|
||||
const int drop = std::max(i - radius, 0);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||
in[static_cast<std::size_t>(drop) * stride + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||
radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
inline void blurVertical(const Image& src, Image& dst, int radius) {
|
||||
const std::size_t stride = src.stride();
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
const std::size_t offset = x * 3;
|
||||
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Blurs `canvas` in place: `passes` box blurs of `radius` along the axes
|
||||
// `direction` names ("both", "horizontal" or "vertical"). Shared by the blur
|
||||
// and the focus pull effects.
|
||||
inline void boxBlur(Image& canvas, int radius, int passes, const std::string& direction) {
|
||||
const int width = canvas.width();
|
||||
const int height = canvas.height();
|
||||
Image scratch(width, height);
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
if (direction != "vertical") {
|
||||
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
if (direction != "horizontal") {
|
||||
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
@@ -24,26 +22,6 @@ const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
|
||||
"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."),
|
||||
ParamSpec::makeInt("split_wobble", "Wobble", 4, 0, 256,
|
||||
"How far in pixels the channels sway back and forth over time.")
|
||||
.forVideo(),
|
||||
ParamSpec::makeDouble("split_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
|
||||
"Sways per second, rounded so that the clip loops without a jump.",
|
||||
0.1, 1)
|
||||
.forVideo(),
|
||||
ParamSpec::makeInt("split_jitter", "Jitter", 2, 0, 256,
|
||||
"Furthest in pixels the channels shake, anew on every frame and, in "
|
||||
"per_band mode, for every band.")
|
||||
.forVideo(),
|
||||
ParamSpec::makeInt("split_roll", "Tracking bar", 16, 0, 512,
|
||||
"How far in pixels a bar rolling down the picture tears the rows "
|
||||
"sideways and the channels apart; 0 for none.")
|
||||
.forVideo(),
|
||||
ParamSpec::makeDouble("split_roll_time", "Bar pass", 4.0, 0.5, 60.0,
|
||||
"Seconds the tracking bar takes from top to bottom, rounded so "
|
||||
"that the clip loops without a jump.",
|
||||
0.5, 1)
|
||||
.forVideo(),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
@@ -57,68 +35,28 @@ struct Offset {
|
||||
|
||||
using ChannelOffsets = std::array<Offset, 3>;
|
||||
|
||||
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) {
|
||||
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))};
|
||||
};
|
||||
moved = -1;
|
||||
if (channels == "red_cyan") {
|
||||
const Offset red = draw();
|
||||
offsets[0] = red;
|
||||
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
||||
} else if (channels == "random") {
|
||||
moved = static_cast<int>(rng.below(2));
|
||||
offsets[static_cast<std::size_t>(moved)] = draw();
|
||||
offsets[rng.below(2)] = draw();
|
||||
} else {
|
||||
for (auto& offset : offsets) offset = draw();
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
// A motion of each channel, handed out the way the mode moves channels: all
|
||||
// three on their own, green and blue opposite red, or only the one channel
|
||||
// that was picked -- so the motion never moves a channel the still left alone.
|
||||
using Motion = std::array<double, 3>;
|
||||
} // namespace
|
||||
|
||||
Motion spread(const Motion& motion, const std::string& channels, int moved) {
|
||||
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]};
|
||||
if (moved >= 0) {
|
||||
Motion only{};
|
||||
only[static_cast<std::size_t>(moved)] = motion[0];
|
||||
return only;
|
||||
}
|
||||
return motion;
|
||||
}
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
struct MotionSettings {
|
||||
int wobble = 0;
|
||||
double wobbleSpeed = 0.0;
|
||||
int jitter = 0;
|
||||
int roll = 0;
|
||||
double rollTime = 1.0;
|
||||
|
||||
explicit MotionSettings(const ParamValues& values)
|
||||
: wobble(static_cast<int>(std::max(0L, paramInt(values, "split_wobble", 4)))),
|
||||
wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "split_jitter", 2)))),
|
||||
roll(static_cast<int>(std::max(0L, paramInt(values, "split_roll", 16)))),
|
||||
rollTime(std::max(0.5, paramDouble(values, "split_roll_time", 4.0))) {}
|
||||
|
||||
bool moves() const { return (wobble > 0 && wobbleSpeed > 0.0) || jitter > 0 || roll > 0; }
|
||||
};
|
||||
|
||||
// Rounds a rate so a whole number of cycles fits the clip, never fewer than
|
||||
// one: the last frame then leads straight back into the first.
|
||||
double loopRate(double perSecond, double clipSeconds) {
|
||||
return std::max(1.0, std::round(perSecond * clipSeconds)) / clipSeconds;
|
||||
}
|
||||
|
||||
Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) {
|
||||
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)));
|
||||
@@ -133,105 +71,20 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
|
||||
const int band = perBand ? bandHeight : height;
|
||||
const int bandCount = (height + band - 1) / band;
|
||||
std::vector<ChannelOffsets> plan;
|
||||
std::vector<int> moved;
|
||||
plan.reserve(static_cast<std::size_t>(bandCount));
|
||||
moved.reserve(static_cast<std::size_t>(bandCount));
|
||||
for (int i = 0; i < bandCount; ++i) {
|
||||
int only = -1;
|
||||
plan.push_back(drawOffsets(rng, amount, channels, only));
|
||||
moved.push_back(only);
|
||||
}
|
||||
|
||||
// Every row's offsets, and a sideways shift of the whole row. A still is
|
||||
// just the band plan; a frame of a video adds the motion to it.
|
||||
std::vector<ChannelOffsets> rows(static_cast<std::size_t>(height));
|
||||
std::vector<int> shift(static_cast<std::size_t>(height), 0);
|
||||
for (int y = 0; y < height; ++y) rows[static_cast<std::size_t>(y)] = plan[static_cast<std::size_t>(y / band)];
|
||||
|
||||
const MotionSettings motion(values);
|
||||
if (frame.video() && motion.moves()) {
|
||||
// The same draws on every frame, after the still's: where each
|
||||
// channel starts its sway and which way it sways. The shake is the
|
||||
// one thing that differs between frames, so it comes from a generator
|
||||
// of the frame's own, seeded by one more draw -- the shared rng leaves
|
||||
// every frame in the same state, and the stages after this one hold
|
||||
// still while it moves.
|
||||
std::array<double, 3> phase{};
|
||||
std::array<double, 3> angle{};
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
phase[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
|
||||
angle[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
|
||||
}
|
||||
Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame.index + 1)));
|
||||
|
||||
const double clip = static_cast<double>(std::max(1, frame.count)) / frame.fps;
|
||||
const double t = frame.seconds();
|
||||
|
||||
Motion swayX{};
|
||||
Motion swayY{};
|
||||
if (motion.wobble > 0 && motion.wobbleSpeed > 0.0) {
|
||||
const double rate = loopRate(motion.wobbleSpeed, clip);
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
const double sway = motion.wobble * std::sin(2.0 * std::numbers::pi * rate * t + phase[c]);
|
||||
swayX[c] = sway * std::cos(angle[c]);
|
||||
swayY[c] = sway * std::sin(angle[c]);
|
||||
}
|
||||
}
|
||||
|
||||
// The tracking bar: an eighth of the picture, soft at both edges,
|
||||
// entering above the top and leaving below the bottom.
|
||||
const double barHeight = std::max(8.0, height / 8.0);
|
||||
const double passes = loopRate(1.0 / motion.rollTime, clip);
|
||||
const double along = std::fmod(t * passes, 1.0);
|
||||
const double barTop = -barHeight + along * (height + barHeight);
|
||||
|
||||
for (int b = 0; b < bandCount; ++b) {
|
||||
Motion jitterX{};
|
||||
Motion jitterY{};
|
||||
if (motion.jitter > 0) {
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
jitterX[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter));
|
||||
// Mostly sideways, as a picture losing its horizontal hold.
|
||||
jitterY[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter)) / 3.0;
|
||||
}
|
||||
}
|
||||
const int only = moved[static_cast<std::size_t>(b)];
|
||||
const Motion dx = spread({swayX[0] + jitterX[0], swayX[1] + jitterX[1], swayX[2] + jitterX[2]},
|
||||
channels, only);
|
||||
const Motion dy = spread({swayY[0] + jitterY[0], swayY[1] + jitterY[1], swayY[2] + jitterY[2]},
|
||||
channels, only);
|
||||
// The bar pulls red one way and blue the other (or the moved
|
||||
// channel alone), whatever the sway is doing.
|
||||
const Motion apart = spread({1.0, 0.0, -1.0}, channels, only);
|
||||
|
||||
const int end = std::min(height, (b + 1) * band);
|
||||
for (int y = b * band; y < end; ++y) {
|
||||
double bar = 0.0;
|
||||
if (motion.roll > 0 && y >= barTop && y < barTop + barHeight) {
|
||||
bar = motion.roll * std::sin(std::numbers::pi * (y - barTop) / barHeight);
|
||||
}
|
||||
auto& offsets = rows[static_cast<std::size_t>(y)];
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
offsets[c].dx += static_cast<int>(std::lround(dx[c] + bar * apart[c]));
|
||||
offsets[c].dy += static_cast<int>(std::lround(dy[c]));
|
||||
}
|
||||
shift[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(bar / 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = rows[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 dx = offset.dx + shift[y];
|
||||
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 - dx, 0, width - 1);
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -241,21 +94,4 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ChannelSplitEffect::animated(const ParamValues& values) const {
|
||||
return MotionSettings(values).moves();
|
||||
}
|
||||
|
||||
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
return renderSplit(sources, width, height, rng, values, FrameTime{});
|
||||
}
|
||||
|
||||
Image ChannelSplitEffect::renderFrame(const std::vector<Image>& sources, int width, int height,
|
||||
Rng& rng, const ParamValues& values,
|
||||
const FrameTime& frame) const {
|
||||
return renderSplit(sources, width, height, rng, values, frame);
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -6,9 +6,6 @@ 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.
|
||||
// In a video the channels do not sit still: they sway, shake from frame to
|
||||
// frame, and a tracking bar rolls down the picture as on a worn tape or an old
|
||||
// tube. A still picture is rendered exactly as it always was.
|
||||
// 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 {
|
||||
@@ -21,12 +18,9 @@ public:
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
bool animated(const ParamValues& values) const override;
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+3
-75
@@ -1,10 +1,9 @@
|
||||
#include "effects/corrupt.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/corrupt_blocks.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
@@ -28,19 +27,6 @@ const std::vector<ParamSpec>& CorruptEffect::params() const {
|
||||
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.");
|
||||
@@ -55,23 +41,6 @@ Image CorruptEffect::render(const std::vector<Image>& sources, int width, int he
|
||||
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) {
|
||||
@@ -88,54 +57,13 @@ Image CorruptEffect::render(const std::vector<Image>& sources, int width, int he
|
||||
continue;
|
||||
}
|
||||
if (!chance(rng.next(), rate)) continue;
|
||||
block = drawBlock(bx);
|
||||
block = drawDamage(rng, kinds, bx, width, height, size);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
paintBlocks(canvas, input, plan, cols, rows, size);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The damage a codec does to one block of the picture. Shared by the corrupt
|
||||
// and the datamosh effects.
|
||||
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
|
||||
};
|
||||
|
||||
// Draws the damage of one block, of the kind `kinds` names ("mixed" picks
|
||||
// one), for a block `bx` blocks from the left of a canvas `width` by `height`.
|
||||
inline Block drawDamage(Rng& rng, const std::string& kinds, int bx, int width, int height,
|
||||
int size) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Lays the damage map `plan` (`cols` by `rows` blocks of `size` pixels, in
|
||||
// reading order) over `canvas`, which starts as a copy of `input`. A displaced
|
||||
// block reads `input` from (sx, sy) on, wrapping across its width.
|
||||
inline void paintBlocks(Image& canvas, const Image& input, const std::vector<Block>& plan, int cols,
|
||||
int rows, int size) {
|
||||
const int width = canvas.width();
|
||||
const int height = canvas.height();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "effects/datamosh.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "effects/corrupt_blocks.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& DatamoshEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("mosh_block", "Block size", 16, 2, 256,
|
||||
"Width and height in pixels of one block, as a codec would cut them."),
|
||||
ParamSpec::makeDouble("mosh_rate", "Rate", 0.1, 0.0, 1.0,
|
||||
"Chance of each block breaking at some moment of the cycle."),
|
||||
ParamSpec::makeChoice("mosh_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::makeDouble("mosh_hold", "Hold", 0.3, 0.01, 1.0,
|
||||
"How much of each cycle a broken block stays broken, give or take "
|
||||
"half; 1 keeps it broken throughout."),
|
||||
ParamSpec::makeInt("mosh_drift", "Drift", 24, 0, 4096,
|
||||
"How far in pixels a displaced block's picture slides while it "
|
||||
"shows."),
|
||||
ParamSpec::makeDouble("mosh_speed", "Speed", 0.5, 0.0, 10.0,
|
||||
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||
"0 holds still.",
|
||||
0.05, 2),
|
||||
ParamSpec::makeBool("mosh_bleed", "Bleed", false,
|
||||
"Carry each broken block's fault on to the end of its row, the way "
|
||||
"one broken bit spoils the rest of a stream."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int size;
|
||||
double rate;
|
||||
std::string kinds;
|
||||
double hold;
|
||||
int drift;
|
||||
double speed;
|
||||
bool bleed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: size(static_cast<int>(std::max(2L, paramInt(values, "mosh_block", 16)))),
|
||||
rate(std::clamp(paramDouble(values, "mosh_rate", 0.1), 0.0, 1.0)),
|
||||
kinds(paramChoice(values, "mosh_kinds", "mixed")),
|
||||
hold(std::clamp(paramDouble(values, "mosh_hold", 0.3), 0.01, 1.0)),
|
||||
drift(static_cast<int>(std::max(0L, paramInt(values, "mosh_drift", 24)))),
|
||||
speed(std::max(0.0, paramDouble(values, "mosh_speed", 0.5))),
|
||||
bleed(paramBool(values, "mosh_bleed", false)) {}
|
||||
};
|
||||
|
||||
// A block that breaks at some moment of the cycle: how, from when, for how
|
||||
// long (both as parts of a cycle), and which way a displaced one smears.
|
||||
struct Break {
|
||||
Block block;
|
||||
double start = 0.0;
|
||||
double length = 0.0;
|
||||
double driftX = 0.0;
|
||||
double driftY = 0.0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image DatamoshEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
const int size = settings.size;
|
||||
const int cols = (width + size - 1) / size;
|
||||
const int rows = (height + size - 1) / size;
|
||||
|
||||
// What stays put for the whole clip: the whole map of breaks, block by
|
||||
// block in reading order, as Corrupt draws its damage.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Break> breaks(static_cast<std::size_t>(cols) * rows);
|
||||
bool any = false;
|
||||
for (int by = 0; by < rows; ++by) {
|
||||
const Break* carried = nullptr;
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
Break& piece = breaks[static_cast<std::size_t>(by) * cols + bx];
|
||||
if (carried != nullptr) {
|
||||
piece = *carried;
|
||||
// A displaced run keeps moving along with the row, so the
|
||||
// stretch reads as one shifted strip.
|
||||
if (piece.block.damage == Damage::Displace) piece.block.sx += size;
|
||||
carried = &piece;
|
||||
continue;
|
||||
}
|
||||
if (!chance(steady.next(), settings.rate)) continue;
|
||||
piece.block = drawDamage(steady, settings.kinds, bx, width, height, size);
|
||||
piece.start = unitDouble(steady.next());
|
||||
piece.length = std::min(1.0, settings.hold * (0.5 + unitDouble(steady.next())));
|
||||
sinCosDegrees(unitDouble(steady.next()) * 360.0, piece.driftY, piece.driftX);
|
||||
if (settings.bleed) carried = &piece;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) return input;
|
||||
|
||||
// Where this frame is in time: which blocks are broken, and how far
|
||||
// through its stretch each displaced one is.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<Block> plan(breaks.size());
|
||||
for (std::size_t i = 0; i < breaks.size(); ++i) {
|
||||
const Break& piece = breaks[i];
|
||||
if (piece.block.damage == Damage::None) continue;
|
||||
const double into = t - piece.start - std::floor(t - piece.start);
|
||||
if (piece.length < 1.0 && into >= piece.length) continue;
|
||||
Block& block = plan[i];
|
||||
block = piece.block;
|
||||
if (block.damage == Damage::Displace && settings.drift > 0) {
|
||||
const double slide = settings.drift * into / piece.length;
|
||||
const int sx = block.sx + static_cast<int>(std::lround(slide * piece.driftX));
|
||||
block.sx = (sx % width + width) % width;
|
||||
block.sy = std::clamp(block.sy + static_cast<int>(std::lround(slide * piece.driftY)), 0,
|
||||
height - 1);
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas = input;
|
||||
paintBlocks(canvas, input, plan, cols, rows, size);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The corrupt effect in motion: blocks of the picture break as a damaged
|
||||
// stream breaks them, and each broken block holds for a stretch of the clip
|
||||
// before it heals, a displaced one smearing its picture along while it shows.
|
||||
//
|
||||
// Which blocks break, how, and when in the cycle each is broken is drawn from
|
||||
// at.steady(); a frame only asks which of them are broken at its moment, so
|
||||
// the clip loops without a jump.
|
||||
class DatamoshEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "datamosh"; }
|
||||
std::string title() const override { return "Datamosh"; }
|
||||
std::string description() const override {
|
||||
return "Breaks blocks of the picture as Corrupt does, each for a stretch of the clip, "
|
||||
"displaced blocks smearing along while they show.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "effects/filmgate.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/grain_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FilmGateEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0,
|
||||
"How far a grain may push a pixel, as a fraction of full scale; "
|
||||
"the grain is new on every frame."),
|
||||
ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32,
|
||||
"Width in pixels of one grain; larger sizes look coarser."),
|
||||
ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64,
|
||||
"Furthest in pixels the picture sways sideways in the gate, and half "
|
||||
"that up and down."),
|
||||
ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0,
|
||||
"How far the brightness may jump from one frame to the next, as a "
|
||||
"fraction of itself."),
|
||||
ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500,
|
||||
"Specks of dust on each frame, every one there for that frame alone."),
|
||||
ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16,
|
||||
"Thin bright lines running down the film, wandering a little and "
|
||||
"now and then missing a frame."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
double grain;
|
||||
int grainSize;
|
||||
int weave;
|
||||
double flicker;
|
||||
int dust;
|
||||
int scratches;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)),
|
||||
grainSize(static_cast<int>(std::max(1L, paramInt(values, "gate_grain_size", 2)))),
|
||||
weave(static_cast<int>(std::max(0L, paramInt(values, "gate_weave", 2)))),
|
||||
flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)),
|
||||
dust(static_cast<int>(std::max(0L, paramInt(values, "gate_dust", 3)))),
|
||||
scratches(static_cast<int>(std::max(0L, paramInt(values, "gate_scratches", 1)))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return unitDouble(rng.next()); }
|
||||
|
||||
// The swings the sway is made of: roughly how often each comes round a
|
||||
// second, and how much of the sway it carries.
|
||||
constexpr std::array<double, 3> kSwingRate = {0.7, 1.9, 4.3};
|
||||
constexpr std::array<double, 3> kSwingShare = {0.5, 0.3, 0.2};
|
||||
|
||||
// Blends a pixel towards `shade` by `mix` out of 256.
|
||||
void towards(std::uint8_t* px, int shade, int mix) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
px[c] = static_cast<std::uint8_t>(px[c] + (shade - px[c]) * mix / 256);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
constexpr double tau = 2.0 * std::numbers::pi;
|
||||
|
||||
// What stays put for the whole clip: where each swing of the sway
|
||||
// starts, and where each scratch runs down the film.
|
||||
Rng steady = at.steady();
|
||||
std::array<double, 3> phaseX{};
|
||||
std::array<double, 3> phaseY{};
|
||||
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||
phaseX[i] = unit(steady) * tau;
|
||||
phaseY[i] = unit(steady) * tau;
|
||||
}
|
||||
std::vector<double> scratchAt(static_cast<std::size_t>(settings.scratches));
|
||||
for (double& x : scratchAt) x = unit(steady);
|
||||
|
||||
// Where this frame is in time: how far the picture has swayed.
|
||||
double swayX = 0.0;
|
||||
double swayY = 0.0;
|
||||
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||
const double turn = at.cycle(kSwingRate[i]) * tau;
|
||||
swayX += kSwingShare[i] * std::sin(turn + phaseX[i]);
|
||||
swayY += kSwingShare[i] * std::sin(turn + phaseY[i]);
|
||||
}
|
||||
const int dx = static_cast<int>(std::lround(swayX * settings.weave));
|
||||
const int dy = static_cast<int>(std::lround(swayY * settings.weave * 0.5));
|
||||
|
||||
// What changes on every frame, all drawn before a pixel is touched: the
|
||||
// flicker, the grain, the dust, and which scratches show and where.
|
||||
Rng fresh = at.fresh();
|
||||
const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0);
|
||||
const int gain = static_cast<int>(std::lround((1.0 + flicker) * 256.0));
|
||||
GrainField grain;
|
||||
if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false);
|
||||
struct Speck {
|
||||
int x;
|
||||
int y;
|
||||
int radius;
|
||||
};
|
||||
std::vector<Speck> specks(static_cast<std::size_t>(settings.dust));
|
||||
for (Speck& speck : specks) {
|
||||
speck.x = static_cast<int>(fresh.inRange(0, width - 1));
|
||||
speck.y = static_cast<int>(fresh.inRange(0, height - 1));
|
||||
speck.radius = static_cast<int>(fresh.inRange(0, 2));
|
||||
}
|
||||
std::vector<int> scratchX;
|
||||
for (const double x : scratchAt) {
|
||||
const int wander = static_cast<int>(fresh.inRange(-2, 2));
|
||||
if (unit(fresh) < 0.85) scratchX.push_back(static_cast<int>(x * (width - 1)) + wander);
|
||||
}
|
||||
|
||||
// The picture moved in the gate, the edges it leaves held from the last
|
||||
// row and column, and lit up or down by the flicker.
|
||||
Image frame(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 int sy = std::clamp(static_cast<int>(y) - dy, 0, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||
const std::uint8_t* from = in + static_cast<std::size_t>(sx) * 3;
|
||||
std::uint8_t* to = out + static_cast<std::size_t>(x) * 3;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
to[c] = static_cast<std::uint8_t>(std::min(255, from[c] * gain >> 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true);
|
||||
|
||||
// Dust lies dark on the picture; a scratch through the emulsion lets the
|
||||
// light through.
|
||||
for (const Speck& speck : specks) {
|
||||
for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) {
|
||||
if (y < 0 || y >= height) continue;
|
||||
for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) {
|
||||
if (x < 0 || x >= width) continue;
|
||||
const int ox = x - speck.x;
|
||||
const int oy = y - speck.y;
|
||||
if (ox * ox + oy * oy > speck.radius * speck.radius) continue;
|
||||
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 16, 220);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const int x : scratchX) {
|
||||
if (x < 0 || x >= width) continue;
|
||||
for (int y = 0; y < height; ++y) {
|
||||
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 255, 150);
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Film running through a projector: the grain is new on every frame, the
|
||||
// picture sways a little in the gate and its brightness flickers, and dust
|
||||
// and scratches pass over it.
|
||||
//
|
||||
// The sway is a sum of slow swings, each a whole number of times in the
|
||||
// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and
|
||||
// are different on every frame, as they are on film.
|
||||
class FilmGateEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "filmgate"; }
|
||||
std::string title() const override { return "Film gate"; }
|
||||
std::string description() const override {
|
||||
return "Film running through a projector: grain new on every frame, as Film grain draws "
|
||||
"it, a picture swaying in the gate, flicker, dust and scratches.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "effects/flow.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/noise_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FlowEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("flow_strength", "Strength", 30, 0, 4096,
|
||||
"How far in pixels the field pushes the image at its strongest."),
|
||||
ParamSpec::makeInt("flow_scale", "Scale", 160, 4, 8192,
|
||||
"Size in pixels of the largest features in the field."),
|
||||
ParamSpec::makeInt("flow_octaves", "Octaves", 3, 1, 6,
|
||||
"Finer layers of noise laid over the first; more give a turbulent "
|
||||
"edge to the flow."),
|
||||
ParamSpec::makeChoice("flow_mode", "Mode", "displace", {"displace", "swirl"},
|
||||
"displace pushes along the field; swirl flows along its contour "
|
||||
"lines, which curls the image into eddies."),
|
||||
ParamSpec::makeInt("flow_travel", "Travel", 80, 0, 4096,
|
||||
"Across how many pixels the field drifts in each cycle; the finer "
|
||||
"layers drift half as far and turn faster."),
|
||||
ParamSpec::makeDouble("flow_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||
"0 holds still.",
|
||||
0.05, 2),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
double strength;
|
||||
double scale;
|
||||
int octaves;
|
||||
bool swirl;
|
||||
int travel;
|
||||
double speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: strength(static_cast<double>(std::max(0L, paramInt(values, "flow_strength", 30)))),
|
||||
scale(static_cast<double>(std::max(4L, paramInt(values, "flow_scale", 160)))),
|
||||
octaves(static_cast<int>(std::clamp(paramInt(values, "flow_octaves", 3), 1L, 6L))),
|
||||
swirl(paramChoice(values, "flow_mode", "displace") == "swirl"),
|
||||
travel(static_cast<int>(std::max(0L, paramInt(values, "flow_travel", 80)))),
|
||||
speed(std::max(0.0, paramDouble(values, "flow_speed", 0.25))) {}
|
||||
};
|
||||
|
||||
// Which way round, and from where, one layer of a field drifts.
|
||||
struct Orbit {
|
||||
double start; // degrees
|
||||
double turn; // +1 or -1
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FlowEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
if (settings.strength <= 0.0) return input;
|
||||
|
||||
// What stays put for the whole clip: the field, drawn a circle's radius
|
||||
// larger on every side than the picture, so it can drift without running
|
||||
// out, and where each of its layers starts its circle, and which way.
|
||||
const double reach = settings.travel * 0.5;
|
||||
const int margin = static_cast<int>(reach) + 2;
|
||||
Rng steady = at.steady();
|
||||
const WarpField field(steady, width + 2 * margin, height + 2 * margin, settings.scale,
|
||||
settings.octaves, settings.swirl);
|
||||
std::vector<Orbit> orbits(static_cast<std::size_t>(settings.octaves) * 2);
|
||||
for (Orbit& orbit : orbits) {
|
||||
orbit.start = unitDouble(steady.next()) * 360.0;
|
||||
orbit.turn = (steady.next() >> 63) != 0 ? 1.0 : -1.0;
|
||||
}
|
||||
|
||||
// Where this frame is in time: layer i has gone i + 1 times round a
|
||||
// circle half as wide as the one before in a whole cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<NoiseShift> firstShifts(static_cast<std::size_t>(settings.octaves));
|
||||
std::vector<NoiseShift> secondShifts(static_cast<std::size_t>(settings.octaves));
|
||||
double radius = reach;
|
||||
for (int i = 0; i < settings.octaves; ++i) {
|
||||
for (int f = 0; f < 2; ++f) {
|
||||
const Orbit& orbit = orbits[static_cast<std::size_t>(i * 2 + f)];
|
||||
double sine = 0.0;
|
||||
double cosine = 1.0;
|
||||
sinCosDegrees(orbit.start + orbit.turn * (i + 1) * 360.0 * t, sine, cosine);
|
||||
NoiseShift& shift = (f == 0 ? firstShifts : secondShifts)[static_cast<std::size_t>(i)];
|
||||
shift.x = margin + radius * cosine;
|
||||
shift.y = margin + radius * sine;
|
||||
}
|
||||
radius *= 0.5;
|
||||
}
|
||||
|
||||
Image frame(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 = frame.row(static_cast<int>(row));
|
||||
const double y = static_cast<double>(row);
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const double x = col;
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
field.push(x, y, settings.strength, dx, dy, &firstShifts, &secondShifts);
|
||||
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The warp effect in motion: the same field of noise pushes or swirls the
|
||||
// picture, and its layers drift, each round a circle of its own, so the
|
||||
// picture ripples like heat haze or water under wind.
|
||||
//
|
||||
// Every layer of the field goes round its circle a whole number of times in a
|
||||
// cycle -- the finer layers smaller and faster -- so the field, and with it
|
||||
// the clip, comes back to where it started without a jump.
|
||||
class FlowEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "flow"; }
|
||||
std::string title() const override { return "Warp flow"; }
|
||||
std::string description() const override {
|
||||
return "Pushes the picture through a field of noise, as Warp does, and sets the field "
|
||||
"drifting so the picture ripples.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "effects/focuspull.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/box_blur.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FocusPullEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("focus_mode", "Mode", "breathe", {"breathe", "band"},
|
||||
"breathe pulls the whole picture out of focus and back; band "
|
||||
"moves a band of sharpness down the picture and back up."),
|
||||
ParamSpec::makeInt("focus_radius", "Radius", 12, 0, 256,
|
||||
"Half width in pixels of the blur at its strongest."),
|
||||
ParamSpec::makeDouble("focus_band", "Band", 0.3, 0.05, 1.0,
|
||||
"Height of the sharp band as a share of the picture, with as much "
|
||||
"again fading into the blur; band mode only."),
|
||||
ParamSpec::makeDouble("focus_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Pulls there and back per second, rounded so that the clip loops "
|
||||
"without a jump; 0 holds still.",
|
||||
0.05, 2),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Two passes of a box blur come close enough to a lens.
|
||||
constexpr int kPasses = 2;
|
||||
|
||||
struct Settings {
|
||||
bool band;
|
||||
int radius;
|
||||
double bandShare;
|
||||
double speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: band(paramChoice(values, "focus_mode", "breathe") == "band"),
|
||||
radius(static_cast<int>(std::max(0L, paramInt(values, "focus_radius", 12)))),
|
||||
bandShare(std::clamp(paramDouble(values, "focus_band", 0.3), 0.05, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "focus_speed", 0.25))) {}
|
||||
};
|
||||
|
||||
Image blurred(const Image& input, int radius) {
|
||||
Image out = input;
|
||||
if (radius > 0) boxBlur(out, radius, kPasses, "both");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Lays `over` on `under` in place, row y at `mix(y)` out of 256.
|
||||
template <typename Mix>
|
||||
void blendRows(Image& under, const Image& over, Mix mix) {
|
||||
const std::size_t bytes = static_cast<std::size_t>(under.width()) * 3;
|
||||
parallelFor(static_cast<std::size_t>(under.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const int weight = mix(static_cast<int>(y));
|
||||
if (weight <= 0) continue;
|
||||
std::uint8_t* out = under.row(static_cast<int>(y));
|
||||
const std::uint8_t* in = over.row(static_cast<int>(y));
|
||||
for (std::size_t i = 0; i < bytes; ++i) {
|
||||
out[i] = static_cast<std::uint8_t>(out[i] + (in[i] - out[i]) * weight / 256);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FocusPullEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
if (settings.radius <= 0) return input;
|
||||
const int height = input.height();
|
||||
|
||||
// Where this frame is in time: how far the pull has gone, easing out from
|
||||
// the start and back to it once a cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
const double pull = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||
|
||||
if (!settings.band) {
|
||||
// A radius between two whole ones is the two blurs mixed, so the
|
||||
// focus drifts smoothly rather than in steps.
|
||||
const double radius = settings.radius * pull;
|
||||
const int lower = static_cast<int>(radius);
|
||||
const int mix = static_cast<int>(std::lround((radius - lower) * 256.0));
|
||||
Image frame = blurred(input, lower);
|
||||
if (mix > 0) blendRows(frame, blurred(input, lower + 1), [mix](int) { return mix; });
|
||||
return frame;
|
||||
}
|
||||
|
||||
// The band's middle goes from the top to the bottom and back; the blur
|
||||
// grows over as much again as the band is high on either side of it.
|
||||
const double middle = pull * (height - 1);
|
||||
const double half = settings.bandShare * height * 0.5;
|
||||
const double fade = std::max(1.0, settings.bandShare * height);
|
||||
const Image soft = blurred(input, settings.radius);
|
||||
blendRows(input, soft, [&](int y) {
|
||||
const double away = std::abs(y - middle) - half;
|
||||
return static_cast<int>(std::lround(std::clamp(away / fade, 0.0, 1.0) * 256.0));
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The blur effect in motion, as a lens being focused: either the whole
|
||||
// picture drifts out of focus and back, or a band of sharpness travels down
|
||||
// the picture and back up with the rest blurred, as a tilted lens shows it.
|
||||
//
|
||||
// The pull goes there and back once a cycle, so the clip loops without a
|
||||
// jump; nothing in it is random.
|
||||
class FocusPullEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "focuspull"; }
|
||||
std::string title() const override { return "Focus pull"; }
|
||||
std::string description() const override {
|
||||
return "Blurs the picture as Blur does, pulling it out of focus and back, or moving a "
|
||||
"band of sharpness up and down it.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
+3
-38
@@ -4,7 +4,7 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/grain_field.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -23,14 +23,6 @@ const std::vector<ParamSpec>& GrainEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -54,35 +46,8 @@ Image GrainEffect::render(const std::vector<Image>& sources, int width, int heig
|
||||
|
||||
// The whole grain field is drawn serially from the rng, exactly like the
|
||||
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||
const int cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
const int channels = colour ? 3 : 1;
|
||||
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
||||
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
||||
// by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const GrainField field = drawGrain(rng, width, height, size, colour);
|
||||
applyGrain(canvas, field, amount, midtones);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A field of grain over a canvas: one value in [-127, 127] per cell of
|
||||
// `size` pixels square, per channel when the grain is coloured. Shared by the
|
||||
// film grain and the film gate effects.
|
||||
struct GrainField {
|
||||
int size = 1;
|
||||
int cellsX = 0;
|
||||
bool colour = false;
|
||||
std::vector<std::int8_t> cells;
|
||||
};
|
||||
|
||||
// Draws the whole field serially from the rng, so that only the pixel
|
||||
// arithmetic that applies it runs in parallel.
|
||||
inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) {
|
||||
GrainField field;
|
||||
field.size = size;
|
||||
field.colour = colour;
|
||||
field.cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
field.cells.resize(static_cast<std::size_t>(field.cellsX) * cellsY * (colour ? 3 : 1));
|
||||
for (auto& cell : field.cells) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
return field;
|
||||
}
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
// Pushes every pixel by its grain, at most `amount` of full scale, and less in
|
||||
// the shadows and highlights when `midtones` is set.
|
||||
inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) {
|
||||
const int width = canvas.width();
|
||||
const int channels = field.colour ? 3 : 1;
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight;
|
||||
// scaling by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / field.size) * field.cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell =
|
||||
(rowCell + static_cast<std::size_t>(x / field.size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const std::size_t plane = field.colour ? static_cast<std::size_t>(c) : 0;
|
||||
const int grain = field.cells[cell + plane];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "core/rng.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One layer of value noise: random heights on a lattice `spacing` pixels
|
||||
// apart, blended smoothly between the lattice points. Positions are read from
|
||||
// (0, 0) to the width and height it was built for.
|
||||
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_;
|
||||
};
|
||||
|
||||
// Where one layer of a field is read from, against where it is asked for.
|
||||
struct NoiseShift {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// As above, with each layer read that far off from (x, y), one shift per
|
||||
// octave -- which is how a field is set moving: its layers slide apart.
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY,
|
||||
const std::vector<NoiseShift>* shifts = nullptr) 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;
|
||||
if (shifts != nullptr) {
|
||||
layers_[i].sample(x + (*shifts)[i].x, y + (*shifts)[i].y, v, sx, sy);
|
||||
} else {
|
||||
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;
|
||||
};
|
||||
|
||||
// The push the warp effects give a pixel: one field for a swirl, which flows
|
||||
// along its contours, two for a push along x and y. The fields are drawn in
|
||||
// full, in that order, when this is built.
|
||||
class WarpField {
|
||||
public:
|
||||
WarpField(Rng& rng, int width, int height, double scale, int octaves, bool swirl)
|
||||
: first_(rng, width, height, scale, octaves), scale_(scale), swirl_(swirl) {
|
||||
if (!swirl) second_.emplace_back(rng, width, height, scale, octaves);
|
||||
}
|
||||
|
||||
// How far the pixel at (x, y) is pushed, at `strength` pixels at most;
|
||||
// `shifts` (one list per field) slides the fields' layers as NoiseField does.
|
||||
void push(double x, double y, double strength, double& dx, double& dy,
|
||||
const std::vector<NoiseShift>* firstShifts = nullptr,
|
||||
const std::vector<NoiseShift>* secondShifts = nullptr) const {
|
||||
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
|
||||
first_.sample(x, y, value, slopeX, slopeY, firstShifts);
|
||||
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;
|
||||
second_.front().sample(x, y, other, slopeX, slopeY, secondShifts);
|
||||
dx = value * strength;
|
||||
dy = other * strength;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
NoiseField first_;
|
||||
std::vector<NoiseField> second_;
|
||||
double scale_;
|
||||
bool swirl_;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/sort_runs.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -34,39 +35,6 @@ const std::vector<ParamSpec>& PixelSortEffect::params() const {
|
||||
|
||||
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
|
||||
@@ -99,49 +67,19 @@ Image PixelSortEffect::render(const std::vector<Image>& sources, int width, int
|
||||
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);
|
||||
};
|
||||
SortRule rule;
|
||||
rule.low = low;
|
||||
rule.high = high;
|
||||
rule.key = sortKeyNamed(keyName);
|
||||
rule.reverse = reverse;
|
||||
rule.maxRun = maxRun;
|
||||
|
||||
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);
|
||||
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// What orders the pixels inside a run. Shared by the pixel sort and the sort
|
||||
// sweep effects.
|
||||
enum class SortKey { Luma, Hue, Saturation };
|
||||
|
||||
inline SortKey sortKeyNamed(const std::string& name) {
|
||||
if (name == "hue") return SortKey::Hue;
|
||||
if (name == "saturation") return SortKey::Saturation;
|
||||
return SortKey::Luma;
|
||||
}
|
||||
|
||||
inline int sortLuma(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.
|
||||
inline int sortHue(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;
|
||||
}
|
||||
|
||||
inline int sortSaturation(const std::uint8_t* px) {
|
||||
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
|
||||
}
|
||||
|
||||
inline int sortKeyOf(SortKey key, const std::uint8_t* px) {
|
||||
switch (key) {
|
||||
case SortKey::Hue: return sortHue(px);
|
||||
case SortKey::Saturation: return sortSaturation(px);
|
||||
case SortKey::Luma: break;
|
||||
}
|
||||
return sortLuma(px);
|
||||
}
|
||||
|
||||
// The image with rows and columns swapped, so vertical sorting is horizontal
|
||||
// sorting of the transpose.
|
||||
inline 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;
|
||||
}
|
||||
|
||||
// How one line is sorted: the brightness a pixel must lie within (0..255) to
|
||||
// join a run, what orders a run, and the longest a run may grow (0 for no
|
||||
// limit), its lengths drawn from `runRng`.
|
||||
struct SortRule {
|
||||
int low = 0;
|
||||
int high = 255;
|
||||
SortKey key = SortKey::Luma;
|
||||
bool reverse = false;
|
||||
int maxRun = 0;
|
||||
};
|
||||
|
||||
// Sorts every run of one line of `span` pixels in place. `run` is scratch
|
||||
// space, reused from line to line.
|
||||
inline void sortLine(std::uint8_t* row, int span, const SortRule& rule, Rng& runRng,
|
||||
std::vector<std::pair<int, std::array<std::uint8_t, 3>>>& run) {
|
||||
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 rule.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 = rule.maxRun > 0 ? static_cast<int>(runRng.inRange(1, rule.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 = sortLuma(px);
|
||||
if (brightness < rule.low || brightness > rule.high) {
|
||||
flush(start);
|
||||
start = x + 1;
|
||||
continue;
|
||||
}
|
||||
if (static_cast<int>(run.size()) >= limit) {
|
||||
flush(start);
|
||||
start = x;
|
||||
if (rule.maxRun > 0) limit = static_cast<int>(runRng.inRange(1, rule.maxRun));
|
||||
}
|
||||
run.push_back({sortKeyOf(rule.key, px), {px[0], px[1], px[2]}});
|
||||
}
|
||||
flush(start);
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "effects/striproll.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& StripRollEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("roll_height", "Strip height", 24, 1, 512,
|
||||
"Height in pixels of each strip."),
|
||||
ParamSpec::makeInt("roll_jitter", "Height jitter", 0, 0, 256,
|
||||
"Random +/- variation added to the strip height (0 = uniform)."),
|
||||
ParamSpec::makeBool("roll_keep_order", "Cycle images in order", false,
|
||||
"Take strips from the images in order instead of picking randomly."),
|
||||
ParamSpec::makeChoice("roll_axis", "Axis", "vertical", {"vertical", "horizontal"},
|
||||
"vertical rolls the picture in each strip up or down through its "
|
||||
"image; horizontal slides it sideways."),
|
||||
ParamSpec::makeChoice("roll_direction", "Direction", "alternate",
|
||||
{"alternate", "same", "random"},
|
||||
"alternate rolls every other strip the other way; same rolls them "
|
||||
"all one way; random picks for each strip."),
|
||||
ParamSpec::makeInt("roll_speed", "Speed", 120, 0, 8192,
|
||||
"Pixels per second the picture rolls, rounded so that each strip goes "
|
||||
"round its image a whole number of times in the clip; 0 holds still."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int stripHeight;
|
||||
int jitter;
|
||||
bool keepOrder;
|
||||
bool horizontal;
|
||||
std::string direction;
|
||||
int speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: stripHeight(static_cast<int>(std::max(1L, paramInt(values, "roll_height", 24)))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "roll_jitter", 0)))),
|
||||
keepOrder(paramBool(values, "roll_keep_order", false)),
|
||||
horizontal(paramChoice(values, "roll_axis", "vertical") == "horizontal"),
|
||||
direction(paramChoice(values, "roll_direction", "alternate")),
|
||||
speed(static_cast<int>(std::max(0L, paramInt(values, "roll_speed", 120)))) {}
|
||||
};
|
||||
|
||||
// One strip of the canvas: where it lies, the image it shows, where in that
|
||||
// image its picture starts, and which way it rolls.
|
||||
struct Strip {
|
||||
int dstY;
|
||||
int height;
|
||||
std::size_t sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
int way; // +1 or -1
|
||||
};
|
||||
|
||||
int wrap(long value, int span) { return static_cast<int>((value % span + span) % span); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image StripRollEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
|
||||
// What stays put for the whole clip: the strips, drawn as Scanlines draws
|
||||
// them, and the way each one rolls.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Strip> strips;
|
||||
strips.reserve(static_cast<std::size_t>(height / settings.stripHeight) + 8);
|
||||
for (int y = 0; y < height;) {
|
||||
Strip strip{};
|
||||
strip.dstY = y;
|
||||
strip.sourceIndex = settings.keepOrder ? strips.size() % sources.size()
|
||||
: steady.below(sources.size() - 1);
|
||||
const Image& src = sources[strip.sourceIndex];
|
||||
int h = settings.stripHeight;
|
||||
if (settings.jitter > 0) {
|
||||
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
}
|
||||
strip.height = std::max(1, std::min(h, height - y));
|
||||
strip.sy = static_cast<int>(steady.inRange(0, src.height() - 1));
|
||||
strip.sx = static_cast<int>(steady.inRange(0, src.width() - 1));
|
||||
if (settings.direction == "random") {
|
||||
strip.way = (steady.next() >> 63) != 0 ? 1 : -1;
|
||||
} else if (settings.direction == "alternate") {
|
||||
strip.way = strips.size() % 2 == 0 ? 1 : -1;
|
||||
} else {
|
||||
strip.way = 1;
|
||||
}
|
||||
strips.push_back(strip);
|
||||
y += strip.height;
|
||||
}
|
||||
|
||||
// Where this frame is in time: how far round its image each strip has
|
||||
// rolled, a whole number of rounds in the clip, the nearest to the speed.
|
||||
std::vector<long> offsets(strips.size(), 0);
|
||||
if (settings.speed > 0) {
|
||||
for (std::size_t i = 0; i < strips.size(); ++i) {
|
||||
const Image& src = sources[strips[i].sourceIndex];
|
||||
const int span = settings.horizontal ? src.width() : src.height();
|
||||
const double rounds = std::max(1.0, std::round(settings.speed * at.length() / span));
|
||||
const double into = std::fmod(at.seconds() * rounds / at.length(), 1.0);
|
||||
offsets[i] = strips[i].way * std::lround(into * span);
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(strips.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
const Strip& strip = strips[i];
|
||||
const Image& src = sources[strip.sourceIndex];
|
||||
const long down = settings.horizontal ? 0 : offsets[i];
|
||||
const long across = settings.horizontal ? offsets[i] : 0;
|
||||
const int startX = wrap(strip.sx + across, src.width());
|
||||
for (int line = 0; line < strip.height; ++line) {
|
||||
const std::uint8_t* in = src.row(wrap(strip.sy + line + down, src.height()));
|
||||
std::uint8_t* out = canvas.row(strip.dstY + line);
|
||||
// Round the image's width as often as the canvas needs.
|
||||
int x = 0;
|
||||
int from = startX;
|
||||
while (x < width) {
|
||||
const int run = std::min(width - x, src.width() - from);
|
||||
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||
in + static_cast<std::size_t>(from) * 3,
|
||||
static_cast<std::size_t>(run) * 3);
|
||||
x += run;
|
||||
from = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The scanlines effect in motion: the canvas is cut into strips as Scanlines
|
||||
// cuts it, each showing a slice of one of the source images, and the picture
|
||||
// in every strip rolls through its image -- up or down, or sideways -- while
|
||||
// the strips themselves stay put.
|
||||
//
|
||||
// Built from the sources, as TileSwapEffect is: it answers needsSources() and
|
||||
// writes compose(). A strip goes round its image a whole number of times in
|
||||
// the clip, so the clip loops without a jump.
|
||||
class StripRollEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "striproll"; }
|
||||
std::string title() const override { return "Strip roll"; }
|
||||
std::string description() const override {
|
||||
return "Cuts the canvas into strips of the source images, as Scanlines does, and rolls "
|
||||
"the picture in each strip through its image.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return true; }
|
||||
|
||||
protected:
|
||||
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "effects/sweep.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/sort_runs.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& SweepEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("sweep_from", "From", 0.1, 0.0, 1.0,
|
||||
"Brightness the middle of the window starts the sweep at."),
|
||||
ParamSpec::makeDouble("sweep_to", "To", 0.9, 0.0, 1.0,
|
||||
"Brightness the middle of the window turns back at."),
|
||||
ParamSpec::makeDouble("sweep_width", "Window", 0.35, 0.0, 1.0,
|
||||
"How wide a band of brightness is sorted at once; pixels outside "
|
||||
"it break a run and stay where they are."),
|
||||
ParamSpec::makeDouble("sweep_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Sweeps there and back per second, rounded so that the clip loops "
|
||||
"without a jump; 0 holds still.",
|
||||
0.05, 2),
|
||||
ParamSpec::makeChoice("sweep_key", "Sort by", "luma", {"luma", "hue", "saturation"},
|
||||
"What orders the pixels inside a run."),
|
||||
ParamSpec::makeChoice("sweep_direction", "Direction", "horizontal",
|
||||
{"horizontal", "vertical"}, "Whether rows or columns are sorted."),
|
||||
ParamSpec::makeBool("sweep_reverse", "Reverse", false,
|
||||
"Sort from high to low instead of from low to high."),
|
||||
ParamSpec::makeDouble("sweep_line_chance", "Line chance", 1.0, 0.0, 1.0,
|
||||
"Chance of each row (or column) being sorted at all."),
|
||||
ParamSpec::makeInt("sweep_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 {
|
||||
|
||||
struct Settings {
|
||||
double from;
|
||||
double to;
|
||||
double width;
|
||||
double speed;
|
||||
SortKey key;
|
||||
bool vertical;
|
||||
bool reverse;
|
||||
double lineChance;
|
||||
int maxRun;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: from(std::clamp(paramDouble(values, "sweep_from", 0.1), 0.0, 1.0)),
|
||||
to(std::clamp(paramDouble(values, "sweep_to", 0.9), 0.0, 1.0)),
|
||||
width(std::clamp(paramDouble(values, "sweep_width", 0.35), 0.0, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "sweep_speed", 0.25))),
|
||||
key(sortKeyNamed(paramChoice(values, "sweep_key", "luma"))),
|
||||
vertical(paramChoice(values, "sweep_direction", "horizontal") == "vertical"),
|
||||
reverse(paramBool(values, "sweep_reverse", false)),
|
||||
lineChance(std::clamp(paramDouble(values, "sweep_line_chance", 1.0), 0.0, 1.0)),
|
||||
maxRun(static_cast<int>(std::max(0L, paramInt(values, "sweep_max_run", 0)))) {}
|
||||
};
|
||||
|
||||
struct LinePlan {
|
||||
bool sorted = false;
|
||||
std::uint64_t seed = 0; // for the run lengths, the same on every frame
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image SweepEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
Image canvas = settings.vertical ? transposed(input) : std::move(input);
|
||||
const int lines = canvas.height();
|
||||
const int span = canvas.width();
|
||||
|
||||
// What stays put for the whole clip: which lines are sorted, and where
|
||||
// their runs are cut.
|
||||
Rng steady = at.steady();
|
||||
std::vector<LinePlan> plan(static_cast<std::size_t>(lines));
|
||||
for (auto& line : plan) {
|
||||
line.sorted = chance(steady.next(), settings.lineChance);
|
||||
line.seed = steady.next();
|
||||
}
|
||||
|
||||
// Where this frame is in time: the middle of the window eases from
|
||||
// `from` to `to` and back once a cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
const double ease = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||
const double middle = settings.from + (settings.to - settings.from) * ease;
|
||||
SortRule rule;
|
||||
rule.low = static_cast<int>(std::lround((middle - settings.width * 0.5) * 255.0));
|
||||
rule.high = static_cast<int>(std::lround((middle + settings.width * 0.5) * 255.0));
|
||||
rule.key = settings.key;
|
||||
rule.reverse = settings.reverse;
|
||||
rule.maxRun = settings.maxRun;
|
||||
|
||||
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);
|
||||
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
|
||||
}
|
||||
});
|
||||
|
||||
return settings.vertical ? transposed(canvas) : canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The pixel sort in motion: only pixels inside a window of brightness are
|
||||
// sorted, and the window sweeps from the dark end to the bright end and back,
|
||||
// so the sorted streaks spread over one part of the picture after another,
|
||||
// melt away and return.
|
||||
//
|
||||
// Which lines are sorted, and how their runs are cut, is drawn from
|
||||
// at.steady() and stays put; the window alone moves, there and back once a
|
||||
// cycle, so the clip loops without a jump.
|
||||
class SweepEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "sweep"; }
|
||||
std::string title() const override { return "Sort sweep"; }
|
||||
std::string description() const override {
|
||||
return "Sorts runs of pixels as Pixel sort does, only within a window of brightness that "
|
||||
"sweeps from dark to bright and back.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeText("text", "Text", "Effector",
|
||||
"What to write; a line break starts a new line."),
|
||||
ParamSpec::makeText("font_family", "Font", "Sans",
|
||||
ParamSpec::makeFont("font_family", "Font", "Sans",
|
||||
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
|
||||
"\"DejaVu Serif\", ...")
|
||||
.styled(),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY). Shared by the tiles and the tile swap effects.
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
inline std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
inline void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
std::size_t filled = copyBytes;
|
||||
while (filled < total) {
|
||||
const std::size_t chunk = std::min(filled, total - filled);
|
||||
std::memcpy(out + filled, out, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
// Paints the whole plan. Overlapping tiles (the scattered layout) are painted
|
||||
// in plan order on one thread: letting threads race for the same pixels would
|
||||
// make the output depend on their scheduling.
|
||||
inline void paintTiles(Image& canvas, const std::vector<Image>& sources,
|
||||
const std::vector<Tile>& plan, bool overlapping) {
|
||||
if (overlapping) {
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
+2
-76
@@ -1,11 +1,10 @@
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY).
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
std::size_t filled = copyBytes;
|
||||
while (filled < total) {
|
||||
const std::size_t chunk = std::min(filled, total - filled);
|
||||
std::memcpy(out + filled, out, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
@@ -159,20 +98,7 @@ Image TilesEffect::render(const std::vector<Image>& sources, int width, int heig
|
||||
}
|
||||
}
|
||||
|
||||
if (layout == "scatter") {
|
||||
// Scattered tiles overlap, so they are painted in plan order on one
|
||||
// thread: letting threads race for the same pixels would make the
|
||||
// output depend on their scheduling.
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
} else {
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
paintTiles(canvas, sources, plan, layout == "scatter");
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "effects/tileswap.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
||||
"grid lines the tiles up in rows and columns; brick packs every row "
|
||||
"on its own so the seams do not meet; scatter drops loose tiles over "
|
||||
"a filled background, overlapping."),
|
||||
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
|
||||
"Width in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
|
||||
"Height in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
|
||||
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
||||
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
|
||||
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
||||
"Take tiles from the images in order instead of picking randomly."),
|
||||
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 30.0,
|
||||
"Swaps per second of each tile, rounded so that the clip loops "
|
||||
"without a jump; 0 for none.",
|
||||
0.1, 1),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
std::string layout;
|
||||
int tileWidth;
|
||||
int tileHeight;
|
||||
int jitter;
|
||||
long count;
|
||||
bool keepOrder;
|
||||
double rate;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: layout(paramChoice(values, "tileswap_layout", "grid")),
|
||||
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
|
||||
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
|
||||
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
|
||||
keepOrder(paramBool(values, "tileswap_keep_order", false)),
|
||||
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
// Where a tile sits for the whole clip, when in its swaps it turns over, and
|
||||
// the seed its cuts come from.
|
||||
struct Cell {
|
||||
int x;
|
||||
int y;
|
||||
int w;
|
||||
int h;
|
||||
double phase;
|
||||
std::uint64_t seed;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const bool scatter = settings.layout == "scatter";
|
||||
Image canvas(width, height);
|
||||
|
||||
// What stays put for the whole clip: the cells, and for loose tiles the
|
||||
// backdrop they lie on.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Cell> cells;
|
||||
auto addCell = [&](int x, int y, int w, int h) {
|
||||
cells.push_back(Cell{x, y, w, h, 0.0, 0});
|
||||
};
|
||||
if (scatter) {
|
||||
const Image& src = sources[steady.below(sources.size() - 1)];
|
||||
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
|
||||
cells.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (long i = 0; i < settings.count; ++i) {
|
||||
int w = settings.tileWidth;
|
||||
int h = settings.tileHeight;
|
||||
if (settings.jitter > 0) {
|
||||
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
}
|
||||
const int x = static_cast<int>(steady.inRange(0, width - 1));
|
||||
const int y = static_cast<int>(steady.inRange(0, height - 1));
|
||||
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
|
||||
}
|
||||
} else {
|
||||
const bool brick = settings.layout == "brick";
|
||||
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
|
||||
std::vector<int> columns;
|
||||
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int y = 0;
|
||||
for (const int rowHeight : rows) {
|
||||
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int x = 0;
|
||||
for (const int cellWidth : columns) {
|
||||
addCell(x, y, cellWidth, rowHeight);
|
||||
x += cellWidth;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
for (Cell& cell : cells) {
|
||||
cell.phase = unit(steady);
|
||||
cell.seed = steady.next();
|
||||
}
|
||||
|
||||
// Where this frame is in time: each tile has swapped a whole number of
|
||||
// times, counted round the clip so that the frame after the last shows
|
||||
// what the first does.
|
||||
long swaps = 0;
|
||||
double perSecond = 0.0;
|
||||
if (settings.rate > 0.0) {
|
||||
perSecond = at.loopRate(settings.rate);
|
||||
swaps = std::max(1L, std::lround(perSecond * at.length()));
|
||||
}
|
||||
|
||||
// What a tile shows depends only on the tile and its swap, so it holds
|
||||
// still between swaps and any frame can be drawn on its own.
|
||||
std::vector<Tile> plan;
|
||||
plan.reserve(cells.size());
|
||||
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||
const Cell& cell = cells[i];
|
||||
std::uint64_t turn = 0;
|
||||
if (swaps > 0) {
|
||||
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + cell.phase));
|
||||
turn = static_cast<std::uint64_t>(passed % swaps);
|
||||
}
|
||||
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
||||
const std::size_t sourceIndex =
|
||||
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
|
||||
const Image& src = sources[sourceIndex];
|
||||
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
|
||||
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
|
||||
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
|
||||
}
|
||||
|
||||
paintTiles(canvas, sources, plan, scatter);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The tiles effect in motion: the canvas is laid with tiles cut from the
|
||||
// source images, and each tile is swapped for another cut, at a moment of its
|
||||
// own, so the mosaic keeps changing piece by piece.
|
||||
//
|
||||
// The video effect built from the sources rather than transforming its input:
|
||||
// it answers needsSources() and writes compose(). The layout is drawn from
|
||||
// at.steady() and stays put; what a tile shows depends only on the tile and
|
||||
// how many times it has swapped, so it holds still between its swaps.
|
||||
class TileSwapEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tileswap"; }
|
||||
std::string title() const override { return "Tile swap"; }
|
||||
std::string description() const override {
|
||||
return "Lays tiles cut from the source images over the canvas, as Tiles does, and swaps "
|
||||
"each one for another cut at a moment of its own.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return true; }
|
||||
|
||||
protected:
|
||||
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "effects/tracking.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TrackingEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("tracking_wobble", "Wobble", 4, 0, 256,
|
||||
"How far in pixels the colour planes sway apart and back."),
|
||||
ParamSpec::makeDouble("tracking_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
|
||||
"Sways per second, rounded so that the clip loops without a jump.",
|
||||
0.1, 1),
|
||||
ParamSpec::makeInt("tracking_jitter", "Jitter", 2, 0, 256,
|
||||
"Furthest in pixels a band shakes, anew on every frame."),
|
||||
ParamSpec::makeInt("tracking_band_height", "Band height", 24, 1, 2048,
|
||||
"Thickness in pixels of the bands that shake on their own."),
|
||||
ParamSpec::makeInt("tracking_bar", "Tracking bar", 16, 0, 512,
|
||||
"How far in pixels the rolling bar tears the rows sideways and the "
|
||||
"colour planes apart; 0 for none."),
|
||||
ParamSpec::makeDouble("tracking_bar_time", "Bar pass", 4.0, 0.5, 60.0,
|
||||
"Seconds the tracking bar takes from top to bottom, rounded so "
|
||||
"that the clip loops without a jump.",
|
||||
0.5, 1),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int wobble;
|
||||
double wobbleSpeed;
|
||||
int jitter;
|
||||
int band;
|
||||
int bar;
|
||||
double barTime;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: wobble(static_cast<int>(std::max(0L, paramInt(values, "tracking_wobble", 4)))),
|
||||
wobbleSpeed(std::max(0.0, paramDouble(values, "tracking_wobble_speed", 0.5))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "tracking_jitter", 2)))),
|
||||
band(static_cast<int>(std::max(1L, paramInt(values, "tracking_band_height", 24)))),
|
||||
bar(static_cast<int>(std::max(0L, paramInt(values, "tracking_bar", 16)))),
|
||||
barTime(std::max(0.5, paramDouble(values, "tracking_bar_time", 4.0))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
// How far each of the red, green and blue planes is moved on one row.
|
||||
struct RowShift {
|
||||
std::array<int, 3> dx{};
|
||||
std::array<int, 3> dy{};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TrackingEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
constexpr double tau = 2.0 * std::numbers::pi;
|
||||
|
||||
// What stays put for the whole clip: where each plane's sway starts, and
|
||||
// the direction it sways in.
|
||||
Rng steady = at.steady();
|
||||
std::array<double, 3> phase{};
|
||||
std::array<double, 3> angle{};
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
phase[c] = unit(steady) * tau;
|
||||
angle[c] = unit(steady) * tau;
|
||||
}
|
||||
|
||||
// Where this frame is in time: the sway of each plane, and the bar -- an
|
||||
// eighth of the picture, soft at both edges, entering above the top and
|
||||
// leaving below the bottom.
|
||||
std::array<double, 3> swayX{};
|
||||
std::array<double, 3> swayY{};
|
||||
if (settings.wobble > 0 && settings.wobbleSpeed > 0.0) {
|
||||
const double turn = at.cycle(settings.wobbleSpeed) * tau;
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
const double sway = settings.wobble * std::sin(turn + phase[c]);
|
||||
swayX[c] = sway * std::cos(angle[c]);
|
||||
swayY[c] = sway * std::sin(angle[c]);
|
||||
}
|
||||
}
|
||||
const double barHeight = std::max(8.0, height / 8.0);
|
||||
const double barTop = -barHeight + at.cycle(1.0 / settings.barTime) * (height + barHeight);
|
||||
|
||||
// What changes on every frame: each band's shake, mostly sideways, as a
|
||||
// picture losing its horizontal hold. All of it drawn, band by band,
|
||||
// before a pixel is touched.
|
||||
Rng fresh = at.fresh();
|
||||
std::vector<RowShift> rows(static_cast<std::size_t>(height));
|
||||
std::vector<int> tear(static_cast<std::size_t>(height), 0);
|
||||
for (int top = 0; top < height; top += settings.band) {
|
||||
std::array<double, 3> jitterX{};
|
||||
std::array<double, 3> jitterY{};
|
||||
if (settings.jitter > 0) {
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
jitterX[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter));
|
||||
jitterY[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter)) / 3.0;
|
||||
}
|
||||
}
|
||||
const int end = std::min(height, top + settings.band);
|
||||
for (int y = top; y < end; ++y) {
|
||||
// The bar pulls red one way and blue the other, and drags the
|
||||
// whole row half as far.
|
||||
double pull = 0.0;
|
||||
if (settings.bar > 0 && y >= barTop && y < barTop + barHeight) {
|
||||
pull = settings.bar * std::sin(std::numbers::pi * (y - barTop) / barHeight);
|
||||
}
|
||||
constexpr std::array<double, 3> apart = {1.0, 0.0, -1.0};
|
||||
auto& row = rows[static_cast<std::size_t>(y)];
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
row.dx[c] = static_cast<int>(std::lround(swayX[c] + jitterX[c] + pull * apart[c]));
|
||||
row.dy[c] = static_cast<int>(std::lround(swayY[c] + jitterY[c]));
|
||||
}
|
||||
tear[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(pull / 2.0));
|
||||
}
|
||||
}
|
||||
|
||||
Image frame(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) {
|
||||
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
const int dx = rows[y].dx[c] + tear[y];
|
||||
const int sy = std::clamp(static_cast<int>(y) - rows[y].dy[c], 0, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A worn tape losing its hold: the red, green and blue planes sway apart and
|
||||
// back, bands of the picture shake sideways from frame to frame, and a
|
||||
// tracking bar rolls down the picture tearing the rows it passes.
|
||||
//
|
||||
// The first video effect, and the one to copy for the next: its params are
|
||||
// declared like any effect's, and animate() reads them, takes what stays put
|
||||
// from at.steady(), what changes every frame from at.fresh(), and time from
|
||||
// at.cycle() -- then draws the frame in one pass over the pixels.
|
||||
class TrackingEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tracking"; }
|
||||
std::string title() const override { return "VHS tracking"; }
|
||||
std::string description() const override {
|
||||
return "A worn tape losing its hold: the colour planes sway and shake, and a tracking bar "
|
||||
"rolls down the picture.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "effects/tunnel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TunnelEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("tunnel_count", "Echoes", 8, 2, 32,
|
||||
"How many echoes are nested inside the picture at once."),
|
||||
ParamSpec::makeDouble("tunnel_scale", "Scale", 0.8, 0.3, 0.95,
|
||||
"Size of each echo against the one around it.", 0.01),
|
||||
ParamSpec::makeDouble("tunnel_rotate", "Rotation", 6.0, -180.0, 180.0,
|
||||
"Turn in degrees of each echo against the one around it.", 0.5, 1),
|
||||
ParamSpec::makeInt("tunnel_drift", "Drift", 0, 0, 4096,
|
||||
"How far in pixels each echo sits off the centre of the one around "
|
||||
"it, all in one direction of the seed's own."),
|
||||
ParamSpec::makeDouble("tunnel_fade", "Opacity", 0.8, 0.0, 1.0,
|
||||
"How strongly each echo covers what lies beneath it."),
|
||||
ParamSpec::makeDouble("tunnel_speed", "Speed", 0.5, 0.0, 10.0,
|
||||
"Echoes passed per second, rounded so that the clip loops without "
|
||||
"a jump; 0 holds still.",
|
||||
0.1, 1),
|
||||
ParamSpec::makeChoice("tunnel_direction", "Direction", "in", {"in", "out"},
|
||||
"in flies into the tunnel, the echoes growing towards you; out "
|
||||
"backs away from it, the echoes shrinking into the distance."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int count;
|
||||
double scale;
|
||||
double rotate;
|
||||
int drift;
|
||||
double fade;
|
||||
double speed;
|
||||
bool out;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: count(static_cast<int>(std::clamp(paramInt(values, "tunnel_count", 8), 2L, 32L))),
|
||||
scale(std::clamp(paramDouble(values, "tunnel_scale", 0.8), 0.3, 0.95)),
|
||||
rotate(std::clamp(paramDouble(values, "tunnel_rotate", 6.0), -180.0, 180.0)),
|
||||
drift(static_cast<int>(std::max(0L, paramInt(values, "tunnel_drift", 0)))),
|
||||
fade(std::clamp(paramDouble(values, "tunnel_fade", 0.8), 0.0, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "tunnel_speed", 0.5))),
|
||||
out(paramChoice(values, "tunnel_direction", "in") == "out") {}
|
||||
};
|
||||
|
||||
// One echo as it lies in this frame: how a pixel of the canvas finds the
|
||||
// pixel of the input it shows, and how strongly it covers what is beneath.
|
||||
struct Echo {
|
||||
double sine;
|
||||
double cosine;
|
||||
double shrink; // 1 / size
|
||||
double dx;
|
||||
double dy;
|
||||
int mix; // out of 256
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TunnelEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
|
||||
// What stays put for the whole clip: the direction the echoes drift in.
|
||||
Rng steady = at.steady();
|
||||
double driftSine = 0.0;
|
||||
double driftCosine = 1.0;
|
||||
sinCosDegrees(unitDouble(steady.next()) * 360.0, driftSine, driftCosine);
|
||||
|
||||
// Where this frame is in time: how far each echo has moved on from its
|
||||
// place at the start of the step. Echo k sits at depth k + 1 - t flying
|
||||
// in, k + t backing out; at depth 0 it is the picture itself, so it can
|
||||
// melt into it, and a new one fades in at the far end as the old leaves.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<Echo> echoes;
|
||||
echoes.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (int k = 0; k < settings.count; ++k) {
|
||||
const double depth = settings.out ? k + t : k + 1.0 - t;
|
||||
const double weight = settings.fade * std::clamp(depth, 0.0, 1.0) *
|
||||
std::clamp(settings.count - depth, 0.0, 1.0);
|
||||
const int mix = static_cast<int>(weight * 256.0 + 0.5);
|
||||
if (mix <= 0) continue;
|
||||
Echo echo{};
|
||||
sinCosDegrees(settings.rotate * depth, echo.sine, echo.cosine);
|
||||
echo.shrink = 1.0 / std::pow(settings.scale, depth);
|
||||
echo.dx = settings.drift * depth * driftCosine;
|
||||
echo.dy = settings.drift * depth * driftSine;
|
||||
echo.mix = mix;
|
||||
echoes.push_back(echo);
|
||||
}
|
||||
if (echoes.empty()) return input;
|
||||
|
||||
// The echoes are laid largest first, so the deeper ones nest inside.
|
||||
const double cx = (width - 1) * 0.5;
|
||||
const double cy = (height - 1) * 0.5;
|
||||
Image frame(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) {
|
||||
const std::uint8_t* base = input.row(static_cast<int>(row));
|
||||
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const std::size_t px = static_cast<std::size_t>(col) * 3;
|
||||
int colour[3] = {base[px], base[px + 1], base[px + 2]};
|
||||
for (const Echo& echo : echoes) {
|
||||
const double rx = col - cx - echo.dx;
|
||||
const double ry = static_cast<double>(row) - cy - echo.dy;
|
||||
const double sx = cx + (rx * echo.cosine + ry * echo.sine) * echo.shrink;
|
||||
const double sy = cy + (-rx * echo.sine + ry * echo.cosine) * echo.shrink;
|
||||
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) continue;
|
||||
std::uint8_t echoed[3];
|
||||
sampleBilinear(input, sx, sy, echoed);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
colour[c] += (echoed[c] - colour[c]) * echo.mix / 256;
|
||||
}
|
||||
}
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[px + static_cast<std::size_t>(c)] = static_cast<std::uint8_t>(colour[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The feedback effect in motion: the picture nested inside itself, each copy
|
||||
// smaller, turned and drifted further than the one around it, and the whole
|
||||
// stack flying inwards (or outwards) without end.
|
||||
//
|
||||
// A copy sits at a depth, and its size, turn and drift follow from that depth
|
||||
// alone. Every frame moves each copy on by part of one step, so after a whole
|
||||
// cycle each copy lies exactly where its neighbour started: a new one fades
|
||||
// in at the far end while the nearest fades out into the picture itself, and
|
||||
// the clip loops without a jump.
|
||||
class TunnelEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tunnel"; }
|
||||
std::string title() const override { return "Echo tunnel"; }
|
||||
std::string description() const override {
|
||||
return "Nests the picture inside itself, as Feedback does, and flies through the echoes "
|
||||
"without end.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "core/video.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
double Moment::length() const noexcept {
|
||||
return static_cast<double>(std::max(1, frame_.count)) / std::max(1, frame_.fps);
|
||||
}
|
||||
|
||||
double Moment::loopRate(double perSecond) const noexcept {
|
||||
const double clip = length();
|
||||
return std::max(1.0, std::round(perSecond * clip)) / clip;
|
||||
}
|
||||
|
||||
double Moment::cycle(double perSecond) const noexcept {
|
||||
return std::fmod(seconds() * loopRate(perSecond), 1.0);
|
||||
}
|
||||
|
||||
Rng Moment::fresh() const noexcept {
|
||||
return Rng(seed_ ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame_.index + 1)));
|
||||
}
|
||||
|
||||
Image VideoEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
return renderFrame(sources, width, height, rng, values, FrameTime{0, kDefaultFps, kDefaultFps});
|
||||
}
|
||||
|
||||
Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
const FrameTime at = frame.video() ? frame : FrameTime{0, kDefaultFps, kDefaultFps};
|
||||
|
||||
// An effect built from the sources takes no crop, only the one seed
|
||||
// everything it draws comes from -- the same value on every frame.
|
||||
if (needsSources()) {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
const std::uint64_t seed = rng.next();
|
||||
return compose(sources, width, height, values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// Both of these take the same values from the shared rng on every frame:
|
||||
// the crop a leading stage starts from, and the one seed everything the
|
||||
// effect draws comes from.
|
||||
Image input = canvasFromSources(sources, width, height, rng);
|
||||
const std::uint64_t seed = rng.next();
|
||||
|
||||
return animate(std::move(input), values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// A video effect writes one of these two; the other is never called.
|
||||
Image VideoEffect::animate(Image, const ParamValues&, const Moment&) const {
|
||||
throw std::logic_error(name() + " is a transform but writes no animate()");
|
||||
}
|
||||
|
||||
Image VideoEffect::compose(const std::vector<Image>&, int, int, const ParamValues&,
|
||||
const Moment&) const {
|
||||
throw std::logic_error(name() + " needs sources but writes no compose()");
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Where one frame sits in its clip, and the randomness it may draw on, as a
|
||||
// video effect is handed it. Time is always read through here: the rates it
|
||||
// hands out are rounded to fit the clip, so whatever an effect does with
|
||||
// them, the last frame leads straight back into the first and a clip loops
|
||||
// without a jump.
|
||||
class Moment {
|
||||
public:
|
||||
Moment(const FrameTime& frame, std::uint64_t seed) noexcept : frame_(frame), seed_(seed) {}
|
||||
|
||||
int index() const noexcept { return frame_.index; }
|
||||
int frames() const noexcept { return frame_.count; }
|
||||
int fps() const noexcept { return frame_.fps; }
|
||||
double seconds() const noexcept { return frame_.seconds(); } // into the clip
|
||||
double length() const noexcept; // of the whole clip
|
||||
|
||||
// The rate nearest `perSecond` at which a whole number of cycles fits the
|
||||
// clip, and never fewer than one.
|
||||
double loopRate(double perSecond) const noexcept;
|
||||
|
||||
// How far through a cycle that happens `perSecond` times a second this
|
||||
// frame is, from 0 at its start towards 1 at its end: the one number most
|
||||
// motion needs -- sin(2π · cycle) sways, cycle · height rolls down.
|
||||
double cycle(double perSecond) const noexcept;
|
||||
|
||||
// Draws that come out the same on every frame of the clip: where a sway
|
||||
// starts, which way it goes -- what makes the motion this seed's own.
|
||||
Rng steady() const noexcept { return Rng(seed_); }
|
||||
|
||||
// Draws that are new on every frame: shake, noise, flicker. The same
|
||||
// frame of the same seed still draws the same values, so frames can be
|
||||
// rendered alone and in any order.
|
||||
Rng fresh() const noexcept;
|
||||
|
||||
private:
|
||||
FrameTime frame_;
|
||||
std::uint64_t seed_;
|
||||
};
|
||||
|
||||
// The base of every video effect, and the one place that keeps the rules a
|
||||
// moving stage has to follow, so an effect built on it is only its look:
|
||||
//
|
||||
// - It takes one value from the shared rng -- the same on every frame -- and
|
||||
// hands the effect generators of its own through the Moment. The stages
|
||||
// after it therefore see the rng in one state throughout and hold still
|
||||
// while it moves.
|
||||
// - It is one of two kinds. A transform (the default) writes animate(): it
|
||||
// needs no source images, and leading a chain it starts from a crop of
|
||||
// one, as the other transforms do. One that builds its frames out of the
|
||||
// sources answers needsSources() and writes compose() instead: it is
|
||||
// handed the loaded photographs when it leads a chain and the stage before
|
||||
// it otherwise, as the image effects are, and takes no crop.
|
||||
// - It never runs in a still project (the chain skips it). Asked for a still
|
||||
// directly, it gives the first frame of a one-second clip.
|
||||
//
|
||||
// A new video effect subclasses this, declares its params like any effect,
|
||||
// and writes animate() or compose(); see TrackingEffect for a transform and
|
||||
// TileSwapEffect for one built from the sources.
|
||||
class VideoEffect : public Effect {
|
||||
public:
|
||||
bool video() const final { return true; }
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const final;
|
||||
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const final;
|
||||
|
||||
protected:
|
||||
// One frame of the clip: `input` is the picture this stage is handed,
|
||||
// already at the canvas size, and what comes back is the same size.
|
||||
// Every random value comes from `at`, never from anywhere else. A
|
||||
// transform writes this.
|
||||
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const;
|
||||
|
||||
// One frame built out of `sources` (never empty) at the canvas size, for
|
||||
// an effect that answers needsSources(); the same rule on randomness.
|
||||
virtual Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
+4
-101
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/noise_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
@@ -26,88 +27,6 @@ const std::vector<ParamSpec>& WarpEffect::params() const {
|
||||
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.");
|
||||
@@ -119,11 +38,8 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
|
||||
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);
|
||||
// The lattices are drawn in full before any pixel moves.
|
||||
const WarpField field(rng, width, height, scale, octaves, swirl);
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
@@ -132,22 +48,9 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
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;
|
||||
}
|
||||
field.push(x, y, strength, dx, dy);
|
||||
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
+470
-74
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/memory.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
@@ -28,6 +29,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
|
||||
const char* const kRecentApp = "effector";
|
||||
const std::size_t kRecentCount = 10;
|
||||
|
||||
// Undo keeps this many steps, and an edit becomes one once the controls have
|
||||
// been still this long (ms): a spin held down or a word typed is one step.
|
||||
const std::size_t kHistoryDepth = 100;
|
||||
constexpr unsigned kHistoryPause = 500;
|
||||
|
||||
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||||
auto filter = Gtk::FileFilter::create();
|
||||
filter->set_name("Effector projects");
|
||||
@@ -50,6 +56,10 @@ constexpr double kClipBudgetBytes = 256.0 * 1024 * 1024;
|
||||
constexpr int kClipMinSide = 160;
|
||||
constexpr int kClipMaxSide = 720;
|
||||
|
||||
// A preview clip renders every third frame first and plays those, each held
|
||||
// for three frames, while the frames between them are filled in.
|
||||
constexpr int kDraftStep = 3;
|
||||
|
||||
int clipSide(int width, int height, int frames) {
|
||||
const double full = 3.0 * width * height * std::max(1, frames);
|
||||
const double scale = std::min(1.0, std::sqrt(kClipBudgetBytes / full));
|
||||
@@ -111,13 +121,21 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
||||
|
||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
||||
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
|
||||
loadFinished_.connect([this] {
|
||||
onLoadFinished();
|
||||
if (historyAfterLoad_ && !loading_) {
|
||||
historyAfterLoad_ = false;
|
||||
resetHistory();
|
||||
}
|
||||
});
|
||||
clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress));
|
||||
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
|
||||
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
|
||||
|
||||
buildUi();
|
||||
applyInitial(initial);
|
||||
historyAfterLoad_ = loading_;
|
||||
resetHistory();
|
||||
|
||||
suspendRender_ = false;
|
||||
if (loading_) {
|
||||
@@ -131,6 +149,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
||||
|
||||
EffectorWindow::~EffectorWindow() {
|
||||
debounce_.disconnect();
|
||||
historyCommit_.disconnect();
|
||||
chainSplitFit_.disconnect();
|
||||
recentChanged_.disconnect();
|
||||
loadWindowDelay_.disconnect();
|
||||
@@ -380,7 +399,7 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
|
||||
// rather than left there to be filled in vain. This is the same declarative
|
||||
// answer the render gate uses -- no effect is named here.
|
||||
void EffectorWindow::updateSourcesVisible() {
|
||||
sourcesSection_.set_visible(chainNeedsSources(stages_));
|
||||
sourcesSection_.set_visible(chainNeedsSources(stages_, isVideo()));
|
||||
}
|
||||
|
||||
// The heading carries the count, which a long list scrolled away would hide.
|
||||
@@ -512,8 +531,8 @@ void EffectorWindow::syncCanvasWidgets() {
|
||||
|
||||
// ---------------------------------------------------------------- video ---
|
||||
|
||||
// A still project shows only the switch; a video shows its preset, length and
|
||||
// rate below it. The preset list reads like the canvas one: "Custom" first,
|
||||
// A still project shows only the switch; a video shows its preset, length, rate
|
||||
// and file quality below it. The preset list reads like the canvas one: "Custom" first,
|
||||
// then every preset with what it amounts to.
|
||||
Gtk::Widget& EffectorWindow::buildVideoSection() {
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||
@@ -572,6 +591,32 @@ Gtk::Widget& EffectorWindow::buildVideoSection() {
|
||||
grid->attach(durationSpin_, 1, 0);
|
||||
grid->attach(*fpsLabel, 0, 1);
|
||||
grid->attach(fpsSpin_, 1, 1);
|
||||
|
||||
// How the saved file is encoded. It changes nothing on screen, so it asks
|
||||
// for no render.
|
||||
std::vector<Glib::ustring> qualityLabels;
|
||||
Glib::ustring qualityTooltip = "How the saved video file is encoded";
|
||||
guint standard = 0;
|
||||
for (std::size_t i = 0; i < videoQualities().size(); ++i) {
|
||||
const auto& quality = videoQualities()[i];
|
||||
qualityLabels.push_back(quality.label);
|
||||
qualityTooltip += "\n" + quality.label + ": " + quality.description;
|
||||
if (quality.key == kDefaultVideoQuality) standard = static_cast<guint>(i);
|
||||
}
|
||||
videoQualityDrop_.set_name("video-quality");
|
||||
videoQualityDrop_.set_model(Gtk::StringList::create(qualityLabels));
|
||||
videoQualityDrop_.set_selected(standard);
|
||||
videoQualityDrop_.set_hexpand(true);
|
||||
videoQualityDrop_.set_tooltip_text(qualityTooltip);
|
||||
// Only the export hears of the quality, so it asks for no render; it is
|
||||
// still part of the project, and of its history.
|
||||
videoQualityDrop_.property_selected().signal_changed().connect([this] {
|
||||
if (!suspendVideoSync_) noteChange();
|
||||
});
|
||||
auto* qualityLabel = Gtk::make_managed<Gtk::Label>("Quality");
|
||||
qualityLabel->set_xalign(0.0F);
|
||||
grid->attach(*qualityLabel, 0, 2);
|
||||
grid->attach(videoQualityDrop_, 1, 2);
|
||||
videoDetails_.append(*grid);
|
||||
videoDetails_.set_visible(false);
|
||||
box->append(videoDetails_);
|
||||
@@ -588,6 +633,8 @@ VideoSettings EffectorWindow::videoSettings() const {
|
||||
if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) {
|
||||
video.preset = presets[selected - 1].key;
|
||||
}
|
||||
const auto quality = videoQualityDrop_.get_selected();
|
||||
if (quality < videoQualities().size()) video.quality = videoQualities()[quality].key;
|
||||
return video;
|
||||
}
|
||||
|
||||
@@ -602,12 +649,18 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
|
||||
if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1);
|
||||
}
|
||||
videoPresetDrop_.set_selected(selected);
|
||||
const auto& quality = videoQualityOrDefault(video.quality);
|
||||
for (std::size_t i = 0; i < videoQualities().size(); ++i) {
|
||||
if (videoQualities()[i].key == quality.key) videoQualityDrop_.set_selected(static_cast<guint>(i));
|
||||
}
|
||||
videoSwitch_.set_active(video.enabled);
|
||||
videoDetails_.set_visible(video.enabled);
|
||||
suspendVideoSync_ = suspended;
|
||||
playbackBar_.set_visible(false);
|
||||
clearClip();
|
||||
buildParamWidgets(); // the motion params come and go with the switch
|
||||
updateVideoEffectItems();
|
||||
rebuildChainRows(); // a video stage runs or is skipped with the switch
|
||||
buildParamWidgets();
|
||||
}
|
||||
|
||||
void EffectorWindow::onVideoToggled() {
|
||||
@@ -618,6 +671,8 @@ void EffectorWindow::onVideoToggled() {
|
||||
result_ = Image();
|
||||
clearClip();
|
||||
saveButton_.set_sensitive(false);
|
||||
updateVideoEffectItems();
|
||||
rebuildChainRows(); // a video stage runs or is skipped with the switch
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
@@ -656,26 +711,59 @@ void EffectorWindow::matchVideoPreset() {
|
||||
}
|
||||
|
||||
// Frames come over from the worker a few at a time and are turned into
|
||||
// textures here, on the main thread. The clip on screen keeps playing until
|
||||
// the new one is complete; the very first one shows its frames as they come.
|
||||
// textures here, on the main thread. They come draft first (draftFirstOrder):
|
||||
// the clip on screen keeps playing until the draft frames are all in, then
|
||||
// the draft takes its place and each later frame drops into it as it comes.
|
||||
// The very first clip shows its frames as they come.
|
||||
void EffectorWindow::onClipProgress() {
|
||||
std::vector<Image> frames;
|
||||
std::vector<std::pair<int, Image>> frames;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
frames.swap(pendingFrames_);
|
||||
}
|
||||
if (!renderingVideo_) return; // left over from a render that was dropped
|
||||
for (const auto& frame : frames) incomingClip_.push_back(toTexture(frame));
|
||||
if (frames.empty()) return;
|
||||
Glib::RefPtr<Gdk::Texture> latest;
|
||||
for (const auto& [index, frame] : frames) {
|
||||
const auto slot = static_cast<std::size_t>(index);
|
||||
if (slot >= incomingClip_.size()) continue; // the clip was cleared meanwhile
|
||||
latest = toTexture(frame);
|
||||
incomingClip_[slot] = latest;
|
||||
++incomingReceived_;
|
||||
if (draftShown_ && slot < clip_.size()) clip_[slot] = latest;
|
||||
}
|
||||
if (!latest) return;
|
||||
|
||||
if (clip_.empty()) {
|
||||
picture_.set_paintable(incomingClip_.back());
|
||||
const int draftFrames = (incomingTotal_ + kDraftStep - 1) / kDraftStep;
|
||||
if (!draftShown_ && incomingReceived_ >= draftFrames) {
|
||||
showIncomingClip();
|
||||
} else if (clip_.empty()) {
|
||||
picture_.set_paintable(latest);
|
||||
previewStack_.set_visible_child("picture");
|
||||
}
|
||||
status_.set_text("Rendering preview · frame " + std::to_string(incomingClip_.size()) + " of " +
|
||||
status_.set_text(std::string(draftShown_ ? "Refining preview" : "Rendering preview") +
|
||||
" · frame " + std::to_string(incomingReceived_) + " of " +
|
||||
std::to_string(incomingTotal_));
|
||||
}
|
||||
|
||||
// Puts the clip being rendered on screen, a frame not rendered yet held from
|
||||
// the one before it.
|
||||
void EffectorWindow::showIncomingClip() {
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> clip;
|
||||
clip.reserve(incomingClip_.size());
|
||||
Glib::RefPtr<Gdk::Texture> held;
|
||||
for (const auto& texture : incomingClip_) {
|
||||
if (texture) held = texture;
|
||||
clip.push_back(held);
|
||||
}
|
||||
if (!clip.empty() && !clip.front()) return; // not even the first frame yet
|
||||
clip_ = std::move(clip);
|
||||
clipFps_ = incomingFps_;
|
||||
draftShown_ = true;
|
||||
if (playFrame_ >= static_cast<int>(clip_.size())) playFrame_ = 0;
|
||||
previewStack_.set_visible_child("picture");
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
void EffectorWindow::showClipFrame(int index) {
|
||||
if (clip_.empty()) return;
|
||||
playFrame_ = std::clamp(index, 0, static_cast<int>(clip_.size()) - 1);
|
||||
@@ -720,6 +808,7 @@ void EffectorWindow::clearClip() {
|
||||
stopPlayback();
|
||||
clip_.clear();
|
||||
incomingClip_.clear();
|
||||
draftShown_ = false;
|
||||
playFrame_ = 0;
|
||||
playbackBar_.set_visible(false);
|
||||
}
|
||||
@@ -804,8 +893,11 @@ void EffectorWindow::buildParamWidgets() {
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
// The settings of a stage that is switched off stay editable: it is still
|
||||
// being worked on, it just is not in the picture for the moment.
|
||||
const bool skipped = stage.effect->video() && !isVideo();
|
||||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||||
(stage.enabled ? " settings" : " settings (off)"));
|
||||
(!stage.enabled ? " settings (off)"
|
||||
: skipped ? " settings (video only)"
|
||||
: " settings"));
|
||||
|
||||
int row = 0;
|
||||
// The style the stage follows, when its effect takes one at all: the
|
||||
@@ -828,9 +920,6 @@ void EffectorWindow::buildParamWidgets() {
|
||||
}
|
||||
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
// How a stage moves means nothing in a still, so it is left out there;
|
||||
// the values stay with the stage for when the project becomes a video.
|
||||
if (spec.videoOnly && !isVideo()) continue;
|
||||
// A styled param is read from the style while one is followed, so the
|
||||
// widgets show what the stage will actually render with.
|
||||
const bool shared = style != nullptr && spec.inStyle;
|
||||
@@ -925,6 +1014,21 @@ void EffectorWindow::buildParamWidgets() {
|
||||
widget = std::move(button);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Font: {
|
||||
// Only the family is chosen here: weight, slant and size are
|
||||
// params of their own.
|
||||
auto button = std::make_unique<Gtk::FontDialogButton>(Gtk::FontDialog::create());
|
||||
button->set_halign(Gtk::Align::START);
|
||||
button->set_level(Gtk::FontLevel::FAMILY);
|
||||
button->set_use_font(true);
|
||||
Pango::FontDescription font;
|
||||
font.set_family(paramText(values, spec.key, spec.defaultChoice));
|
||||
button->set_font_desc(font);
|
||||
button->property_font_desc().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(button);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice: {
|
||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||
@@ -981,6 +1085,14 @@ ParamValues EffectorWindow::paramValues() const {
|
||||
values[entry.spec.key] = formatColor(rgb);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Font: {
|
||||
// A family the machine lacks keeps its name, so a project
|
||||
// from elsewhere still says which font it wanted.
|
||||
const auto family =
|
||||
static_cast<Gtk::FontDialogButton*>(entry.widget)->get_font_desc().get_family();
|
||||
if (!family.empty()) values[entry.spec.key] = std::string(family);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice: {
|
||||
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
||||
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
|
||||
@@ -1152,6 +1264,7 @@ void EffectorWindow::onRenameStyle() {
|
||||
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
|
||||
}
|
||||
buildParamWidgets();
|
||||
noteChange(); // the picture is the same, so no render asks for it
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1318,6 +1431,7 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
|
||||
|
||||
loading_ = true;
|
||||
loadCancel_.store(false, std::memory_order_relaxed);
|
||||
loadSized_.store(0, std::memory_order_relaxed);
|
||||
loadDone_.store(0, std::memory_order_relaxed);
|
||||
loadTotal_ = paths.size();
|
||||
{
|
||||
@@ -1339,6 +1453,14 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
|
||||
loader_ = std::thread([this, paths] {
|
||||
LoadProgress progress;
|
||||
progress.cancel = &loadCancel_;
|
||||
progress.onSized = [this](std::size_t sized, std::size_t, const std::string& path) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||||
loadCurrent_ = path;
|
||||
}
|
||||
loadSized_.store(sized, std::memory_order_relaxed);
|
||||
loadProgress_.emit();
|
||||
};
|
||||
progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||||
@@ -1349,26 +1471,41 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
|
||||
};
|
||||
|
||||
std::vector<LoadFailure> failures;
|
||||
auto images = loadImages(paths, failures, &progress);
|
||||
std::vector<Image> images;
|
||||
std::string refused;
|
||||
try {
|
||||
images = loadImages(paths, failures, &progress);
|
||||
} catch (const OutOfMemoryError& error) {
|
||||
refused = error.what();
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||||
loadedImages_ = std::move(images);
|
||||
loadFailures_ = std::move(failures);
|
||||
loadRefused_ = std::move(refused);
|
||||
loadRefusedPaths_ = loadRefused_.empty() ? std::vector<std::string>{} : paths;
|
||||
}
|
||||
loadFinished_.emit();
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onLoadProgress() {
|
||||
if (loadCancel_.load(std::memory_order_relaxed)) return; // keep "Cancelling…" up
|
||||
const std::size_t sized = loadSized_.load(std::memory_order_relaxed);
|
||||
const std::size_t done = loadDone_.load(std::memory_order_relaxed);
|
||||
std::string current;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||||
current = loadCurrent_;
|
||||
}
|
||||
// The headers are all read before the first decode starts, so the bar
|
||||
// runs twice: once sizing the batch, then once decoding it.
|
||||
if (loadBar_ != nullptr && loadTotal_ > 0) {
|
||||
loadBar_->set_fraction(static_cast<double>(done) / static_cast<double>(loadTotal_));
|
||||
loadBar_->set_text(std::to_string(done) + " of " + std::to_string(loadTotal_));
|
||||
const bool sizing = sized < loadTotal_ && done == 0;
|
||||
const std::size_t count = sizing ? sized : done;
|
||||
loadBar_->set_fraction(static_cast<double>(count) / static_cast<double>(loadTotal_));
|
||||
loadBar_->set_text((sizing ? "Checking sizes: " : "Decoding: ") + std::to_string(count) +
|
||||
" of " + std::to_string(loadTotal_));
|
||||
}
|
||||
if (loadFileLabel_ != nullptr && !current.empty()) {
|
||||
loadFileLabel_->set_text(std::filesystem::path(current).filename().string());
|
||||
@@ -1384,12 +1521,18 @@ void EffectorWindow::onLoadFinished() {
|
||||
|
||||
std::vector<Image> images;
|
||||
std::vector<LoadFailure> failures;
|
||||
std::string refused;
|
||||
std::vector<std::string> refusedPaths;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||||
images = std::move(loadedImages_);
|
||||
failures = std::move(loadFailures_);
|
||||
refused = std::move(loadRefused_);
|
||||
refusedPaths = std::move(loadRefusedPaths_);
|
||||
loadedImages_.clear();
|
||||
loadFailures_.clear();
|
||||
loadRefused_.clear();
|
||||
loadRefusedPaths_.clear();
|
||||
}
|
||||
|
||||
if (loadCancel_.load(std::memory_order_relaxed)) {
|
||||
@@ -1406,6 +1549,22 @@ void EffectorWindow::onLoadFinished() {
|
||||
dropLoadedImages_ = false;
|
||||
images.clear();
|
||||
failures.clear();
|
||||
refused.clear();
|
||||
refusedPaths.clear();
|
||||
}
|
||||
|
||||
if (!refused.empty()) {
|
||||
// Nothing of a refused batch was decoded. Its paths are kept for the
|
||||
// project file, and whatever was queued behind it still gets its turn.
|
||||
refusedSources_.insert(refusedSources_.end(), refusedPaths.begin(), refusedPaths.end());
|
||||
showError(refused + " They were not loaded, but stay in the project when it is saved. "
|
||||
"Close other programs, or load fewer or smaller images.");
|
||||
if (!loadQueue_.empty()) {
|
||||
auto queued = std::move(loadQueue_);
|
||||
loadQueue_.clear();
|
||||
addPaths(queued);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& image : images) appendSourceRow(image.path());
|
||||
@@ -1452,7 +1611,7 @@ void EffectorWindow::showLoadWindow() {
|
||||
|
||||
auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
|
||||
bar->set_show_text(true);
|
||||
bar->set_text("0 of " + std::to_string(loadTotal_));
|
||||
bar->set_text("Checking sizes: 0 of " + std::to_string(loadTotal_));
|
||||
box->append(*bar);
|
||||
loadBar_ = bar;
|
||||
|
||||
@@ -1676,39 +1835,86 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
|
||||
return *box;
|
||||
}
|
||||
|
||||
// The tallest the menu of effects grows before it scrolls, in pixels.
|
||||
constexpr int addMenuMaxHeight = 480;
|
||||
// The tallest each part of the menu of effects grows before it scrolls, in
|
||||
// pixels: together, with their headings, short enough to open below a button
|
||||
// near the top of a small screen.
|
||||
constexpr int addMenuImageHeight = 300;
|
||||
constexpr int addMenuVideoHeight = 150;
|
||||
|
||||
// One entry per registered effect, so a new effect shows up here without this
|
||||
// file knowing anything about it.
|
||||
// file knowing anything about it. The image effects and the video effects
|
||||
// are two sections, each under its own heading and each scrolling on its own,
|
||||
// so the video effects stay in sight however many image effects there are;
|
||||
// they can only be picked in a video project.
|
||||
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||
auto* popover = Gtk::make_managed<Gtk::Popover>();
|
||||
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||||
auto* sections = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||||
|
||||
for (const auto& effect : effects_) {
|
||||
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||||
item->add_css_class("flat");
|
||||
item->set_tooltip_text(effect->description());
|
||||
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
|
||||
item->signal_clicked().connect([this, effect] {
|
||||
addEffectButton_.popdown();
|
||||
appendStage(effect);
|
||||
});
|
||||
list->append(*item);
|
||||
for (const bool video : {false, true}) {
|
||||
if (video) {
|
||||
auto* separator = Gtk::make_managed<Gtk::Separator>();
|
||||
separator->set_margin_top(6);
|
||||
separator->set_margin_bottom(4);
|
||||
sections->append(*separator);
|
||||
}
|
||||
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||
heading->set_margin_start(8);
|
||||
heading->set_margin_top(4);
|
||||
heading->set_margin_bottom(2);
|
||||
heading->append(*Gtk::make_managed<Gtk::Image>(Gio::ThemedIcon::create(
|
||||
video ? "camera-video-symbolic" : "camera-photo-symbolic")));
|
||||
auto* title = Gtk::make_managed<Gtk::Label>(video ? "Video effects" : "Image effects");
|
||||
title->add_css_class("heading");
|
||||
heading->append(*title);
|
||||
sections->append(*heading);
|
||||
if (video) {
|
||||
videoEffectsHint_ = Gtk::make_managed<Gtk::Label>("Switch on Video under Setup to add these.");
|
||||
videoEffectsHint_->add_css_class("dim-label");
|
||||
videoEffectsHint_->set_xalign(0.0F);
|
||||
videoEffectsHint_->set_margin_start(8);
|
||||
videoEffectsHint_->set_wrap(true);
|
||||
videoEffectsHint_->set_max_width_chars(28);
|
||||
sections->append(*videoEffectsHint_);
|
||||
}
|
||||
|
||||
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||||
for (const auto& effect : effects_) {
|
||||
if (effect->video() != video) continue;
|
||||
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||||
item->add_css_class("flat");
|
||||
item->set_tooltip_text(effect->description());
|
||||
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
|
||||
item->signal_clicked().connect([this, effect] {
|
||||
addEffectButton_.popdown();
|
||||
appendStage(effect);
|
||||
});
|
||||
if (video) videoEffectItems_.push_back(item);
|
||||
list->append(*item);
|
||||
}
|
||||
|
||||
// A popover that does not fit on the screen is not shown at all, and
|
||||
// the lists grow with every effect registered, so past a point they
|
||||
// scroll.
|
||||
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(video ? addMenuVideoHeight : addMenuImageHeight);
|
||||
scroller->set_child(*list);
|
||||
sections->append(*scroller);
|
||||
}
|
||||
updateVideoEffectItems();
|
||||
|
||||
// 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);
|
||||
popover->set_child(*sections);
|
||||
return *popover;
|
||||
}
|
||||
|
||||
// A video effect has nothing to do in a still, so it is offered only once the
|
||||
// project is a video.
|
||||
void EffectorWindow::updateVideoEffectItems() {
|
||||
for (auto* item : videoEffectItems_) item->set_sensitive(isVideo());
|
||||
if (videoEffectsHint_ != nullptr) videoEffectsHint_->set_visible(!isVideo());
|
||||
}
|
||||
|
||||
void EffectorWindow::appendStage(const EffectPtr& effect) {
|
||||
stages_.push_back({effect, effect->defaults()});
|
||||
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
||||
@@ -1751,7 +1957,8 @@ void EffectorWindow::rebuildChainRows() {
|
||||
|
||||
// The first stage that actually runs is the one handed the sources, which
|
||||
// is not stage 1 once stage 1 is switched off.
|
||||
const auto running = enabledStages(stages_);
|
||||
const bool video = isVideo();
|
||||
const auto running = enabledStages(stages_, video);
|
||||
const int firstRunning =
|
||||
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
|
||||
|
||||
@@ -1778,13 +1985,28 @@ void EffectorWindow::rebuildChainRows() {
|
||||
});
|
||||
box->append(*check);
|
||||
|
||||
// A video effect is marked as one, and in a still it is dimmed like a
|
||||
// stage that is switched off: the render skips it either way.
|
||||
const bool videoEffect = stages_[i].effect->video();
|
||||
const bool skipped = !stages_[i].enabled || (videoEffect && !video);
|
||||
if (videoEffect) {
|
||||
auto* icon = Gtk::make_managed<Gtk::Image>(
|
||||
Gio::ThemedIcon::create("camera-video-symbolic"));
|
||||
icon->set_tooltip_text("A video effect");
|
||||
icon->set_margin_start(4);
|
||||
icon->set_margin_end(2);
|
||||
if (skipped) icon->add_css_class("dim-label");
|
||||
box->append(*icon);
|
||||
}
|
||||
|
||||
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
|
||||
stageLabels_.push_back(label);
|
||||
label->set_xalign(0.0F);
|
||||
label->set_hexpand(true);
|
||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||
if (!stages_[i].enabled) label->add_css_class("dim-label");
|
||||
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
|
||||
if (skipped) label->add_css_class("dim-label");
|
||||
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
|
||||
: skipped ? "A video effect: skipped in a still picture"
|
||||
: index == firstRunning ? "Applied to the source images"
|
||||
: "Applied to the output so far");
|
||||
box->append(*label);
|
||||
@@ -2012,6 +2234,7 @@ void EffectorWindow::onRecreate() {
|
||||
seed_ = Rng::randomSeed();
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
if (!canRender()) {
|
||||
noteChange();
|
||||
showError(missingText());
|
||||
return;
|
||||
}
|
||||
@@ -2022,13 +2245,18 @@ void EffectorWindow::onRecreate() {
|
||||
// ever handed the sources, so an effect that does not want them (blur) renders
|
||||
// happily with none loaded.
|
||||
bool EffectorWindow::canRender() const {
|
||||
if (enabledStages(stages_).empty()) return false;
|
||||
return !sources_->empty() || !chainNeedsSources(stages_);
|
||||
if (enabledStages(stages_, isVideo()).empty()) return false;
|
||||
return !sources_->empty() || !chainNeedsSources(stages_, isVideo());
|
||||
}
|
||||
|
||||
std::string EffectorWindow::missingText() const {
|
||||
if (stages_.empty()) return "Add an effect to start.";
|
||||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
||||
if (enabledStages(stages_, isVideo()).empty()) {
|
||||
if (!enabledStages(stages_, true).empty()) {
|
||||
return "Video effects run only in a video: switch on Video under Setup.";
|
||||
}
|
||||
return "Every effect is switched off.";
|
||||
}
|
||||
return "Add source images under Setup to start.";
|
||||
}
|
||||
|
||||
@@ -2043,6 +2271,9 @@ void EffectorWindow::showPlaceholder() {
|
||||
}
|
||||
|
||||
void EffectorWindow::requestRender() {
|
||||
// Every edit of the project asks for a render, so this is where the
|
||||
// history hears of them.
|
||||
noteChange();
|
||||
if (suspendRender_) return;
|
||||
if (!canRender()) {
|
||||
showPlaceholder();
|
||||
@@ -2088,8 +2319,11 @@ void EffectorWindow::startRender() {
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.clear();
|
||||
}
|
||||
incomingClip_.clear();
|
||||
incomingTotal_ = video.frameCount();
|
||||
incomingClip_.assign(static_cast<std::size_t>(incomingTotal_), nullptr);
|
||||
incomingReceived_ = 0;
|
||||
incomingFps_ = video.fps;
|
||||
draftShown_ = false;
|
||||
status_.set_text("Rendering preview…");
|
||||
}
|
||||
|
||||
@@ -2100,19 +2334,22 @@ void EffectorWindow::startRender() {
|
||||
try {
|
||||
if (video.enabled) {
|
||||
// Rendered at the full size, so the preview shows what the
|
||||
// file will hold, and shrunk only to be kept.
|
||||
// file will hold, and shrunk only to be kept. A draft of
|
||||
// every few frames comes first, so the clip plays early.
|
||||
const int side = clipSide(width, height, video.frameCount());
|
||||
renderFrames(stages, *sources, width, height, seed, styles, video,
|
||||
[this, side](int, Image&& frame) {
|
||||
if (renderCancel_.load(std::memory_order_relaxed)) return false;
|
||||
Image small = frame.scaledToFit(side);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.push_back(std::move(small));
|
||||
}
|
||||
clipProgress_.emit();
|
||||
return true;
|
||||
});
|
||||
renderFrames(
|
||||
stages, *sources, width, height, seed, styles, video,
|
||||
[this, side](int index, Image&& frame) {
|
||||
if (renderCancel_.load(std::memory_order_relaxed)) return false;
|
||||
Image small = frame.scaledToFit(side);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||||
pendingFrames_.emplace_back(index, std::move(small));
|
||||
}
|
||||
clipProgress_.emit();
|
||||
return true;
|
||||
},
|
||||
draftFirstOrder(video.frameCount(), kDraftStep));
|
||||
} else {
|
||||
Rng rng(seed);
|
||||
image = renderChain(stages, *sources, width, height, rng, styles);
|
||||
@@ -2172,16 +2409,12 @@ void EffectorWindow::onRenderFinished() {
|
||||
std::ostringstream status;
|
||||
status.setf(std::ios::fixed);
|
||||
status.precision(1);
|
||||
const std::size_t effects = enabledStages(stages_).size();
|
||||
const std::size_t effects = enabledStages(stages_, wasVideo).size();
|
||||
if (wasVideo) {
|
||||
result_ = Image();
|
||||
clip_ = std::move(incomingClip_);
|
||||
showIncomingClip();
|
||||
incomingClip_.clear();
|
||||
clipFps_ = videoSettings().fps;
|
||||
if (playFrame_ >= static_cast<int>(clip_.size())) playFrame_ = 0;
|
||||
previewStack_.set_visible_child("picture");
|
||||
saveButton_.set_sensitive(!clip_.empty());
|
||||
startPlayback();
|
||||
status << clip_.size() << " frames · " << effects << (effects == 1 ? " effect" : " effects")
|
||||
<< " · " << millis / 1000.0 << " s";
|
||||
} else {
|
||||
@@ -2195,7 +2428,7 @@ void EffectorWindow::onRenderFinished() {
|
||||
|
||||
// The line itself stays short; the full chain and setup go in the tooltip.
|
||||
std::ostringstream details;
|
||||
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
|
||||
details << chainTitle(stages_, wasVideo) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
|
||||
<< static_cast<int>(heightSpin_.get_value()) << " · ";
|
||||
if (wasVideo) {
|
||||
const auto video = videoSettings();
|
||||
@@ -2228,10 +2461,16 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
|
||||
});
|
||||
// Never enabled: it only stands in for an empty list.
|
||||
add_action("no-recent")->set_enabled(false);
|
||||
undoAction_ = add_action("undo", sigc::mem_fun(*this, &EffectorWindow::undo));
|
||||
redoAction_ = add_action("redo", sigc::mem_fun(*this, &EffectorWindow::redo));
|
||||
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 edit = Gio::Menu::create();
|
||||
edit->append("Undo", "win.undo");
|
||||
edit->append("Redo", "win.redo");
|
||||
menu->append_section(edit);
|
||||
auto open = Gio::Menu::create();
|
||||
open->append("New Project", "win.new-project");
|
||||
open->append("Open Project…", "win.open-project");
|
||||
@@ -2267,12 +2506,27 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
|
||||
}
|
||||
add_controller(shortcuts);
|
||||
|
||||
// Undo and redo are caught before the focused widget sees them: an entry
|
||||
// would otherwise undo its own text, and a spin button only the digits
|
||||
// typed into it, rather than the step the project took.
|
||||
auto history = Gtk::ShortcutController::create();
|
||||
history->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
|
||||
const Accel historyAccels[] = {{"<Control>z", "win.undo"},
|
||||
{"<Control>y", "win.redo"},
|
||||
{"<Control><Shift>z", "win.redo"}};
|
||||
for (const Accel& accel : historyAccels) {
|
||||
history->add_shortcut(Gtk::Shortcut::create(
|
||||
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
|
||||
}
|
||||
add_controller(history);
|
||||
|
||||
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
|
||||
}
|
||||
|
||||
Project EffectorWindow::currentProject() const {
|
||||
Project project;
|
||||
for (const auto& image : *sources_) project.sources.push_back(image.path());
|
||||
project.sources.insert(project.sources.end(), refusedSources_.begin(), refusedSources_.end());
|
||||
project.stages = stages_;
|
||||
project.styles = styles_;
|
||||
project.width = static_cast<int>(widthSpin_.get_value());
|
||||
@@ -2305,6 +2559,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
|
||||
projectPath_ = path;
|
||||
lastExport_.clear();
|
||||
updateTitle();
|
||||
clearError();
|
||||
suspendRender_ = false;
|
||||
@@ -2314,12 +2569,15 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
|
||||
} else {
|
||||
addPaths(project.sources); // renders once the images are decoded
|
||||
}
|
||||
historyAfterLoad_ = loading_;
|
||||
resetHistory();
|
||||
}
|
||||
|
||||
void EffectorWindow::clearSources() {
|
||||
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
|
||||
updateSourcesLabel();
|
||||
mutateSources([](SourceList& list) { list.clear(); });
|
||||
refusedSources_.clear();
|
||||
loadQueue_.clear();
|
||||
if (loading_) dropLoadedImages_ = true;
|
||||
|
||||
@@ -2577,7 +2835,7 @@ void EffectorWindow::onSaveProjectAs() {
|
||||
dialog->set_title("Save project");
|
||||
dialog->set_filters(projectFilters());
|
||||
dialog->set_initial_name(projectPath_.empty()
|
||||
? "effector-" + chainSlug(stages_) + ".json"
|
||||
? "effector-" + chainSlug(stages_, isVideo()) + ".json"
|
||||
: std::filesystem::path(projectPath_).filename().string());
|
||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onSaveProjectChosen(result, dialog);
|
||||
@@ -2621,6 +2879,123 @@ void EffectorWindow::updateTitle() {
|
||||
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- history ---
|
||||
|
||||
EffectorWindow::HistoryState EffectorWindow::captureState() const {
|
||||
HistoryState state;
|
||||
state.project = currentProject();
|
||||
state.json = projectToJson(state.project);
|
||||
state.sources = sources_;
|
||||
state.refusedSources = refusedSources_;
|
||||
state.selectedStage = selectedStage_;
|
||||
return state;
|
||||
}
|
||||
|
||||
// Puts a step back the way a project is opened, but with the images already
|
||||
// in hand: the source list is the very one the step held.
|
||||
void EffectorWindow::restoreState(const HistoryState& state) {
|
||||
suspendHistory_ = true;
|
||||
suspendRender_ = true;
|
||||
closeStyleWindow(); // it may name a style the step does not hold
|
||||
const bool wasVideo = isVideo();
|
||||
const Project& project = state.project;
|
||||
|
||||
stages_ = project.stages;
|
||||
styles_ = project.styles;
|
||||
selectedStage_ =
|
||||
stages_.empty() ? 0 : std::clamp(state.selectedStage, 0, static_cast<int>(stages_.size()) - 1);
|
||||
widthSpin_.set_value(project.width);
|
||||
heightSpin_.set_value(project.height);
|
||||
setCanvasFormat(project.format, project.dpi);
|
||||
setVideoSettings(project.video); // rebuilds the chain rows and the params
|
||||
seed_ = project.seed.value_or(seed_);
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
|
||||
if (state.sources != sources_) {
|
||||
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
|
||||
sources_ = state.sources;
|
||||
for (const auto& image : *sources_) appendSourceRow(image.path());
|
||||
updateSourcesLabel();
|
||||
}
|
||||
refusedSources_ = state.refusedSources;
|
||||
|
||||
if (isVideo() != wasVideo) {
|
||||
// What is on screen is the other kind of result, as in onVideoToggled.
|
||||
result_ = Image();
|
||||
saveButton_.set_sensitive(false);
|
||||
}
|
||||
clearError();
|
||||
suspendRender_ = false;
|
||||
requestRender();
|
||||
suspendHistory_ = false;
|
||||
}
|
||||
|
||||
void EffectorWindow::noteChange() {
|
||||
if (suspendHistory_) return;
|
||||
// Something is waiting to become a step, so there is something to undo
|
||||
// even before the timer runs.
|
||||
if (undoAction_) undoAction_->set_enabled(true);
|
||||
historyCommit_.disconnect();
|
||||
historyCommit_ = Glib::signal_timeout().connect(
|
||||
[this] {
|
||||
commitHistory();
|
||||
return false;
|
||||
},
|
||||
kHistoryPause);
|
||||
}
|
||||
|
||||
void EffectorWindow::commitHistory() {
|
||||
historyCommit_.disconnect();
|
||||
HistoryState now = captureState();
|
||||
if (now.json != committed_.json) {
|
||||
// The step remembers the stage being worked on as it was left, so
|
||||
// undoing it lands on that stage again.
|
||||
committed_.selectedStage = selectedStage_;
|
||||
undo_.push_back(std::move(committed_));
|
||||
if (undo_.size() > kHistoryDepth) undo_.erase(undo_.begin());
|
||||
redo_.clear();
|
||||
}
|
||||
committed_ = std::move(now);
|
||||
updateHistoryActions();
|
||||
}
|
||||
|
||||
void EffectorWindow::resetHistory() {
|
||||
historyCommit_.disconnect();
|
||||
undo_.clear();
|
||||
redo_.clear();
|
||||
committed_ = captureState();
|
||||
updateHistoryActions();
|
||||
}
|
||||
|
||||
void EffectorWindow::undo() {
|
||||
commitHistory(); // an edit still waiting on the timer is the step undone
|
||||
if (undo_.empty()) return;
|
||||
committed_.selectedStage = selectedStage_;
|
||||
redo_.push_back(std::move(committed_));
|
||||
const HistoryState previous = std::move(undo_.back());
|
||||
undo_.pop_back();
|
||||
restoreState(previous);
|
||||
committed_ = captureState();
|
||||
updateHistoryActions();
|
||||
}
|
||||
|
||||
void EffectorWindow::redo() {
|
||||
commitHistory(); // a fresh edit clears the redo list, as it should
|
||||
if (redo_.empty()) return;
|
||||
committed_.selectedStage = selectedStage_;
|
||||
undo_.push_back(std::move(committed_));
|
||||
const HistoryState next = std::move(redo_.back());
|
||||
redo_.pop_back();
|
||||
restoreState(next);
|
||||
committed_ = captureState();
|
||||
updateHistoryActions();
|
||||
}
|
||||
|
||||
void EffectorWindow::updateHistoryActions() {
|
||||
if (undoAction_) undoAction_->set_enabled(!undo_.empty());
|
||||
if (redoAction_) redoAction_->set_enabled(!redo_.empty());
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- saving ---
|
||||
|
||||
void EffectorWindow::onSave() {
|
||||
@@ -2629,8 +3004,27 @@ void EffectorWindow::onSave() {
|
||||
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title(video ? "Save video" : "Save image");
|
||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
|
||||
(video ? ".mp4" : ".png"));
|
||||
// The project's own folder when it has one, else where the last save went.
|
||||
std::filesystem::path folder;
|
||||
if (!projectPath_.empty()) {
|
||||
folder = std::filesystem::path(projectPath_).parent_path();
|
||||
} else if (!lastExport_.empty()) {
|
||||
folder = std::filesystem::path(lastExport_).parent_path();
|
||||
}
|
||||
if (!folder.empty()) dialog->set_initial_folder(Gio::File::create_for_path(folder.string()));
|
||||
// The last save's name counted on, so saving again never offers to
|
||||
// overwrite it; its extension only when it is the same kind of file.
|
||||
std::string name;
|
||||
if (lastExport_.empty()) {
|
||||
name = "effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) +
|
||||
(video ? ".mp4" : ".png");
|
||||
} else {
|
||||
std::filesystem::path last(lastExport_);
|
||||
if (isVideoPath(last.string()) != video) last.replace_extension(video ? ".mp4" : ".png");
|
||||
if (!folder.empty()) last = folder / last.filename();
|
||||
name = std::filesystem::path(nextExportPath(last.string())).filename().string();
|
||||
}
|
||||
dialog->set_initial_name(name);
|
||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onSaveChosen(result, dialog);
|
||||
});
|
||||
@@ -2666,6 +3060,7 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
lastExport_ = path.string();
|
||||
status_.set_text("Saved " + path.string());
|
||||
status_.set_tooltip_text("Saved " + path.string());
|
||||
}
|
||||
@@ -2731,7 +3126,7 @@ void EffectorWindow::startExport(const std::string& path) {
|
||||
exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] {
|
||||
std::string error;
|
||||
try {
|
||||
VideoWriter writer(path, width, height, video.fps);
|
||||
VideoWriter writer(path, width, height, video.fps, video.quality);
|
||||
renderFrames(stages, *sources, width, height, seed, styles, video,
|
||||
[this, &writer](int, Image&& frame) {
|
||||
if (exportCancel_.load(std::memory_order_relaxed)) return false;
|
||||
@@ -2784,6 +3179,7 @@ void EffectorWindow::onExportFinished() {
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
lastExport_ = exportPath_;
|
||||
status_.set_text("Saved " + exportPath_);
|
||||
status_.set_tooltip_text("Saved " + exportPath_);
|
||||
}
|
||||
|
||||
+57
-3
@@ -10,6 +10,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
@@ -69,7 +70,7 @@ private:
|
||||
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||
void addPaths(const std::vector<std::string>& paths); // decodes in the background
|
||||
void onLoadProgress(); // dispatcher: one file finished decoding
|
||||
void onLoadProgress(); // dispatcher: one file sized or decoded
|
||||
void onLoadFinished(); // dispatcher: the batch is done or was cancelled
|
||||
void showLoadWindow();
|
||||
void closeLoadWindow();
|
||||
@@ -102,6 +103,7 @@ private:
|
||||
|
||||
// The preview of a video is the whole clip, rendered first and then looped.
|
||||
void onClipProgress(); // dispatcher: frames arrived from the worker
|
||||
void showIncomingClip(); // the clip in flight takes the screen, gaps held
|
||||
void showClipFrame(int index);
|
||||
void startPlayback();
|
||||
void stopPlayback();
|
||||
@@ -118,6 +120,7 @@ private:
|
||||
|
||||
Gtk::Widget& buildChainSection();
|
||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||
void updateVideoEffectItems(); // offers the video effects in a video only
|
||||
void appendStage(const EffectPtr& effect);
|
||||
void removeStage(int index);
|
||||
void moveStage(int from, int to); // `to` is the index it ends up at
|
||||
@@ -158,6 +161,26 @@ private:
|
||||
void writeProject(const std::string& path);
|
||||
void updateTitle();
|
||||
|
||||
// Undo and redo. The history is a list of whole snapshots of the project,
|
||||
// taken a moment after the edits stop, so a spin held down or a word typed
|
||||
// is one step rather than one per tick. The sources ride along as the
|
||||
// immutable list they already are, so stepping back never decodes again.
|
||||
struct HistoryState {
|
||||
std::string json; // what the step is compared by
|
||||
Project project;
|
||||
std::shared_ptr<const std::vector<Image>> sources;
|
||||
std::vector<std::string> refusedSources;
|
||||
int selectedStage = 0;
|
||||
};
|
||||
HistoryState captureState() const;
|
||||
void restoreState(const HistoryState& state);
|
||||
void noteChange(); // coalesced: restarts the short history timer
|
||||
void commitHistory(); // the current project becomes a step, if it changed
|
||||
void resetHistory(); // a new or opened project starts with none
|
||||
void undo();
|
||||
void redo();
|
||||
void updateHistoryActions();
|
||||
|
||||
void onParamChanged();
|
||||
// The chooser behind a Path param. The chosen file goes straight into the
|
||||
// stage's values rather than into the widget, which may well have been
|
||||
@@ -203,22 +226,43 @@ private:
|
||||
Image result_;
|
||||
std::uint64_t seed_ = 0;
|
||||
std::string projectPath_; // empty until the project has been saved once
|
||||
// The last image or video saved from this project, which the next Save
|
||||
// offers again with its number counted on; empty until one is saved.
|
||||
std::string lastExport_;
|
||||
Glib::RefPtr<Gio::Menu> recentMenu_;
|
||||
sigc::connection recentChanged_; // the recent list outlives the window
|
||||
bool suspendRender_ = true;
|
||||
|
||||
std::vector<HistoryState> undo_;
|
||||
std::vector<HistoryState> redo_;
|
||||
HistoryState committed_; // the project as the last step left it
|
||||
sigc::connection historyCommit_;
|
||||
bool suspendHistory_ = false; // set while a step is being put back
|
||||
// The sources of a project being opened are not a step of their own: the
|
||||
// history starts over once they are in.
|
||||
bool historyAfterLoad_ = false;
|
||||
Glib::RefPtr<Gio::SimpleAction> undoAction_;
|
||||
Glib::RefPtr<Gio::SimpleAction> redoAction_;
|
||||
|
||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||
// it runs off the main thread and reports into a small progress window.
|
||||
std::thread loader_;
|
||||
Glib::Dispatcher loadProgress_;
|
||||
Glib::Dispatcher loadFinished_;
|
||||
std::atomic<bool> loadCancel_{false};
|
||||
std::atomic<std::size_t> loadSized_{0}; // headers read (sizing the batch)
|
||||
std::atomic<std::size_t> loadDone_{0};
|
||||
std::size_t loadTotal_ = 0;
|
||||
std::mutex loadMutex_;
|
||||
std::string loadCurrent_;
|
||||
std::vector<Image> loadedImages_;
|
||||
std::vector<LoadFailure> loadFailures_;
|
||||
std::string loadRefused_; // why the batch would not fit in memory, if it would not
|
||||
std::vector<std::string> loadRefusedPaths_;
|
||||
// Sources refused as too large to hold. They are not shown or rendered,
|
||||
// but a project saved now keeps them, so a refusal never costs the
|
||||
// project its images.
|
||||
std::vector<std::string> refusedSources_;
|
||||
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
||||
bool loading_ = false;
|
||||
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
||||
@@ -239,12 +283,17 @@ private:
|
||||
bool renderingVideo_ = false; // what the render in flight is making
|
||||
|
||||
// The clip being previewed: small frames, ready to show, and the frames
|
||||
// of the clip being rendered, which take its place once it is complete.
|
||||
// of the clip being rendered, by index, which take its place once its
|
||||
// draft frames are in (showIncomingClip).
|
||||
Glib::Dispatcher clipProgress_;
|
||||
std::vector<Image> pendingFrames_; // handed over under resultMutex_
|
||||
std::vector<std::pair<int, Image>> pendingFrames_; // by index, under resultMutex_
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_;
|
||||
int incomingTotal_ = 0;
|
||||
int incomingReceived_ = 0;
|
||||
int incomingFps_ = kDefaultFps;
|
||||
// The clip on screen is the one being rendered, its draft frames all in.
|
||||
bool draftShown_ = false;
|
||||
int clipFps_ = kDefaultFps;
|
||||
int playFrame_ = 0;
|
||||
bool playing_ = true;
|
||||
@@ -287,6 +336,7 @@ private:
|
||||
Gtk::DropDown videoPresetDrop_;
|
||||
Gtk::SpinButton durationSpin_;
|
||||
Gtk::SpinButton fpsSpin_;
|
||||
Gtk::DropDown videoQualityDrop_; // videoQualities()[index]; only the export hears of it
|
||||
bool suspendVideoSync_ = false; // set while code, not the user, fills them
|
||||
// Set while a format fills the spins, so their own handler does not queue
|
||||
// a second render on top of the one the format change asks for.
|
||||
@@ -301,6 +351,10 @@ private:
|
||||
Gtk::Entry seedEntry_;
|
||||
Gtk::ListBox chainList_;
|
||||
Gtk::MenuButton addEffectButton_;
|
||||
// The entries of the video effects in its menu, and the line saying why
|
||||
// they cannot be picked in a still; both owned by the menu.
|
||||
std::vector<Gtk::Button*> videoEffectItems_;
|
||||
Gtk::Label* videoEffectsHint_ = nullptr;
|
||||
Gtk::MenuButton menuButton_;
|
||||
Gtk::Button recreateButton_{"Recreate"};
|
||||
Gtk::Button saveButton_{"Save As…"};
|
||||
|
||||
+24
-6
@@ -25,7 +25,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
|
||||
const bool showProgress = isatty(fileno(stderr)) != 0;
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps);
|
||||
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps,
|
||||
options.video.quality);
|
||||
effector::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video,
|
||||
[&](int index, effector::Image&& frame) {
|
||||
writer.write(frame);
|
||||
@@ -42,8 +43,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
|
||||
|
||||
std::cout << options.output << " " << options.width << "x" << options.height << " "
|
||||
<< count << " frames at " << options.video.fps << " fps effect="
|
||||
<< effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed
|
||||
<< std::setprecision(1) << seconds << " s\n";
|
||||
<< effector::chainSlug(stages, true) << " seed=" << seed << " render="
|
||||
<< std::fixed << std::setprecision(1) << seconds << " s\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,17 @@ int renderHeadless(const effector::Options& options) {
|
||||
stages.push_back({effect, effect->defaults()});
|
||||
}
|
||||
|
||||
// A video effect has nothing to do in a still; the render skips it, and
|
||||
// says so here rather than leave it looking forgotten.
|
||||
if (!options.video.enabled) {
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.enabled || !stage.effect->video()) continue;
|
||||
std::cerr << "effector: skipping " << stage.effect->name()
|
||||
<< ": a video effect, and this is a still (give --duration or "
|
||||
"--video-preset for a clip)\n";
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<effector::LoadFailure> failures;
|
||||
|
||||
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
||||
@@ -80,6 +92,11 @@ int renderHeadless(const effector::Options& options) {
|
||||
std::mutex progressMutex;
|
||||
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
|
||||
if (showProgress) {
|
||||
progress.onSized = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(progressMutex);
|
||||
std::cerr << "\r\033[Ksizing " << done << "/" << total << " "
|
||||
<< std::filesystem::path(path).filename().string() << std::flush;
|
||||
};
|
||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(progressMutex);
|
||||
std::cerr << "\r\033[Kreading " << done << "/" << total << " "
|
||||
@@ -91,7 +108,7 @@ int renderHeadless(const effector::Options& options) {
|
||||
for (const auto& failure : failures) {
|
||||
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
||||
}
|
||||
if (sources.empty() && effector::chainNeedsSources(stages)) {
|
||||
if (sources.empty() && effector::chainNeedsSources(stages, options.video.enabled)) {
|
||||
std::cerr << "effector: no usable source images\n";
|
||||
return 2;
|
||||
}
|
||||
@@ -108,7 +125,7 @@ int renderHeadless(const effector::Options& options) {
|
||||
image.save(options.output);
|
||||
|
||||
std::cout << options.output << " " << options.width << "x" << options.height
|
||||
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
|
||||
<< " effect=" << effector::chainSlug(stages, false) << " seed=" << seed << " render="
|
||||
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -129,7 +146,8 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
if (options.listEffects) {
|
||||
for (const auto& effect : effector::effects()) {
|
||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
||||
std::cout << effect->name() << "\t" << effect->title()
|
||||
<< (effect->video() ? " (video)" : "") << " - "
|
||||
<< effect->description() << "\n";
|
||||
}
|
||||
return 0;
|
||||
|
||||
+485
-37
@@ -20,6 +20,7 @@
|
||||
#include "cli/options.hpp"
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/memory.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
@@ -34,6 +35,7 @@
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -1067,7 +1069,8 @@ int main() {
|
||||
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");
|
||||
check(!chainNeedsSources({stage, scanlines}, false),
|
||||
"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 &&
|
||||
@@ -1194,8 +1197,8 @@ int main() {
|
||||
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||
|
||||
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
||||
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
@@ -1210,10 +1213,10 @@ int main() {
|
||||
// them, blur only softens what it is handed and runs without any.
|
||||
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}),
|
||||
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}, false),
|
||||
"a chain led by blur needs none, later stages see the stage before them");
|
||||
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
||||
check(!chainNeedsSources({}, false), "an empty chain needs no sources");
|
||||
|
||||
Rng bare(5);
|
||||
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||
@@ -1240,12 +1243,12 @@ int main() {
|
||||
Rng skipping(5);
|
||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
||||
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
||||
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
||||
check(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
|
||||
|
||||
// Whoever leads the chain is handed the sources, and that is the first
|
||||
// stage still switched on.
|
||||
check(!chainNeedsSources({scanlinesOff, blur}),
|
||||
check(!chainNeedsSources({scanlinesOff, blur}, false),
|
||||
"a disabled leading stage leaves the question to the stage behind it");
|
||||
Rng leading(5);
|
||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||
@@ -1278,6 +1281,21 @@ int main() {
|
||||
{
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
||||
std::filesystem::create_directories(dir);
|
||||
{
|
||||
const auto base = (dir / "city.png").string();
|
||||
std::filesystem::remove(dir / "city-1.png");
|
||||
std::filesystem::remove(dir / "city-2.png");
|
||||
check(nextExportPath(base) == (dir / "city-1.png").string(),
|
||||
"the next export after a plain name counts from 1");
|
||||
check(nextExportPath((dir / "city-1.png").string()) == (dir / "city-2.png").string(),
|
||||
"the next export counts on from the last number");
|
||||
std::ofstream((dir / "city-1.png").string()) << "x";
|
||||
check(nextExportPath(base) == (dir / "city-2.png").string(),
|
||||
"the next export skips a name already on disk");
|
||||
std::filesystem::remove(dir / "city-1.png");
|
||||
check(nextExportPath((dir / "a-b.mp4").string()) == (dir / "a-b-1.mp4").string(),
|
||||
"a dash before letters is not a count");
|
||||
}
|
||||
const auto source = dir / "source.png";
|
||||
gradient(64, 48, 7).save(source.string());
|
||||
|
||||
@@ -1690,9 +1708,16 @@ int main() {
|
||||
paths.push_back((dir / "missing.png").string());
|
||||
|
||||
std::atomic<std::size_t> calls{0};
|
||||
std::atomic<std::size_t> sizedCalls{0};
|
||||
std::atomic<bool> sizedAfterDecode{false};
|
||||
std::size_t lastTotal = 0;
|
||||
std::mutex mutex;
|
||||
LoadProgress progress;
|
||||
progress.onSized = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||
++sizedCalls;
|
||||
if (calls.load() > 0) sizedAfterDecode = true;
|
||||
check(done >= 1 && done <= total, "sizing step is within the batch");
|
||||
};
|
||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
++calls;
|
||||
@@ -1705,6 +1730,8 @@ int main() {
|
||||
check(images.size() == 6, "every readable image is returned");
|
||||
check(failuresOut.size() == 1, "the unreadable one is reported");
|
||||
check(calls.load() == paths.size(), "progress fires once per file");
|
||||
check(sizedCalls.load() == paths.size() && !sizedAfterDecode.load(),
|
||||
"every header is sized, all before the first decode");
|
||||
check(lastTotal == paths.size(), "progress reports the batch size");
|
||||
check(images.front().path() == paths.front(), "input order is preserved");
|
||||
|
||||
@@ -1718,6 +1745,72 @@ int main() {
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "memory\n";
|
||||
{
|
||||
check(availableMemory() > 0, "the free memory can be read");
|
||||
check(formatBytes(std::size_t{5} << 29) == "2.5 GB" && formatBytes(std::size_t{380} << 20) == "380 MB",
|
||||
"sizes are written in GB and MB");
|
||||
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_memory";
|
||||
std::filesystem::create_directories(dir);
|
||||
std::vector<std::string> paths;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const auto path = dir / ("src" + std::to_string(i) + ".png");
|
||||
gradient(400, 300, i + 1).save(path.string());
|
||||
paths.push_back(path.string());
|
||||
}
|
||||
check(imageFilePixels(paths[0]) == 400u * 300 && imageFilePixels((dir / "missing.png").string()) == 0,
|
||||
"an image is sized from its header");
|
||||
|
||||
// Three 400 x 300 images hold 1 MB decoded; with 1 MB free they do not fit.
|
||||
limitAvailableMemory(std::size_t{1} << 20);
|
||||
std::atomic<int> decoded{0};
|
||||
LoadProgress counting;
|
||||
counting.onFile = [&](std::size_t, std::size_t, const std::string&) { ++decoded; };
|
||||
std::vector<LoadFailure> failures;
|
||||
bool refused = false;
|
||||
std::string message;
|
||||
try {
|
||||
loadImages(paths, failures, &counting);
|
||||
} catch (const OutOfMemoryError& error) {
|
||||
refused = true;
|
||||
message = error.what();
|
||||
}
|
||||
check(refused, "a batch too large for the free memory is refused");
|
||||
check(decoded.load() == 0, "before a single file is decoded");
|
||||
check(message.find("Loading these 3 images needs about") == 0 && message.find("with 1 MB free") != std::string::npos,
|
||||
"and the message says how much is needed and how much is free");
|
||||
|
||||
const std::vector<ChainStage> scanlines = {{effectByName("scanlines"), {}}};
|
||||
const std::vector<Image> small = {gradient(64, 64, 7)};
|
||||
bool renderRefused = false;
|
||||
try {
|
||||
Rng rng(1);
|
||||
renderChain(scanlines, small, 4000, 4000, rng);
|
||||
} catch (const OutOfMemoryError&) {
|
||||
renderRefused = true;
|
||||
}
|
||||
check(renderRefused, "a render too large for the free memory is refused");
|
||||
bool videoRefused = false;
|
||||
try {
|
||||
VideoSettings clip;
|
||||
clip.enabled = true;
|
||||
renderFrames(scanlines, small, 4000, 4000, 1, {}, clip, [](int, Image&&) { return true; });
|
||||
} catch (const OutOfMemoryError&) {
|
||||
videoRefused = true;
|
||||
}
|
||||
check(videoRefused, "and so is a clip");
|
||||
Rng smallRng(1);
|
||||
check(renderChain(scanlines, small, 100, 100, smallRng).width() == 100,
|
||||
"a render that fits still runs");
|
||||
|
||||
limitAvailableMemory(0);
|
||||
failures.clear();
|
||||
check(loadImages(paths, failures).size() == 3, "with the memory there the batch loads");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "video\n";
|
||||
{
|
||||
using effector::ChainStage;
|
||||
@@ -1754,44 +1847,87 @@ int main() {
|
||||
"a video is recognised by its extension");
|
||||
|
||||
const auto split = effector::effectByName("channelsplit");
|
||||
const auto tracking = effector::effectByName("tracking");
|
||||
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||
|
||||
// A still ignores every motion param: the picture is the one it
|
||||
// always was, whatever they are set to.
|
||||
auto calm = split->defaults();
|
||||
calm["split_mode"] = std::string("per_band");
|
||||
auto wild = calm;
|
||||
wild["split_wobble"] = 40L;
|
||||
wild["split_jitter"] = 30L;
|
||||
wild["split_roll"] = 60L;
|
||||
effector::Rng stillA(5);
|
||||
effector::Rng stillB(5);
|
||||
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
|
||||
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
|
||||
"motion params leave a still untouched");
|
||||
check(tracking->video() && !split->video(),
|
||||
"VHS tracking is a video effect, channel split an image one");
|
||||
bool imagesFirst = true;
|
||||
bool seenVideo = false;
|
||||
for (const auto& effect : effects()) {
|
||||
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
|
||||
seenVideo = seenVideo || effect->video();
|
||||
}
|
||||
check(imagesFirst, "the registry lists the image effects before the video effects");
|
||||
|
||||
check(split->animated(split->defaults()), "channel split moves by default in a video");
|
||||
auto frozen = split->defaults();
|
||||
frozen["split_wobble"] = 0L;
|
||||
frozen["split_jitter"] = 0L;
|
||||
frozen["split_roll"] = 0L;
|
||||
check(!split->animated(frozen), "and holds still with every motion at 0");
|
||||
auto wild = tracking->defaults();
|
||||
wild["tracking_wobble"] = 40L;
|
||||
wild["tracking_jitter"] = 30L;
|
||||
wild["tracking_bar"] = 60L;
|
||||
auto frozen = tracking->defaults();
|
||||
frozen["tracking_wobble"] = 0L;
|
||||
frozen["tracking_jitter"] = 0L;
|
||||
frozen["tracking_bar"] = 0L;
|
||||
|
||||
// A moving stage leaves the shared rng in the same state on every
|
||||
// A still skips a video effect whole, as it skips a stage switched
|
||||
// off: the picture is the one the chain made without it.
|
||||
ChainStage scanlinesStage{effectByName("scanlines"), {}};
|
||||
ChainStage trackingStage{tracking, wild};
|
||||
effector::Rng withRng(5);
|
||||
effector::Rng withoutRng(5);
|
||||
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
|
||||
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
|
||||
"a still skips a video effect as though it were not there");
|
||||
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
|
||||
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
|
||||
"and names it only in a video");
|
||||
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
|
||||
!chainNeedsSources({trackingStage, scanlinesStage}, true),
|
||||
"led by a video effect, a still starts from the stage after it");
|
||||
bool onlyVideo = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({trackingStage}, sources, 96, 64, rng);
|
||||
} catch (const EffectError&) {
|
||||
onlyVideo = true;
|
||||
}
|
||||
check(onlyVideo, "a still of video effects alone is refused");
|
||||
|
||||
// A video effect leaves the shared rng in the same state on every
|
||||
// frame, so what comes after it holds still.
|
||||
effector::Rng frameA(9);
|
||||
effector::Rng frameB(9);
|
||||
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
||||
|
||||
// Time is rounded to fit the clip, so the frame after the last is the
|
||||
// first again, and a clip loops without a jump.
|
||||
const effector::Moment start(FrameTime{0, 30, 30}, 1);
|
||||
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
|
||||
"a rate is rounded to a whole number of cycles in the clip");
|
||||
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
|
||||
"half-way through the clip is half-way through its one cycle");
|
||||
auto smooth = wild;
|
||||
smooth["tracking_jitter"] = 0L;
|
||||
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
|
||||
effector::Rng loopA(4);
|
||||
effector::Rng loopB(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
|
||||
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
|
||||
"without the shake, the clip loops without a jump");
|
||||
effector::Rng stillFrame(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
|
||||
input[0]),
|
||||
"with every motion at 0 it leaves its input alone");
|
||||
|
||||
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
|
||||
ChainStage built;
|
||||
built.effect = effector::effectByName(name);
|
||||
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
||||
return built;
|
||||
};
|
||||
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")};
|
||||
std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
|
||||
VideoSettings video;
|
||||
video.enabled = true;
|
||||
video.duration = 1.0;
|
||||
@@ -1829,7 +1965,7 @@ int main() {
|
||||
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
||||
"frames of a moving chain differ");
|
||||
|
||||
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")};
|
||||
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
|
||||
std::vector<effector::Image> held;
|
||||
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
@@ -1840,13 +1976,32 @@ int main() {
|
||||
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
||||
effector::Rng stillRng(21);
|
||||
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
|
||||
"a chain that does not move renders its still on every frame");
|
||||
"a chain of image effects renders its still on every frame");
|
||||
|
||||
int seen = 0;
|
||||
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&&) { return ++seen < 3; });
|
||||
check(seen == 3, "returning false stops a clip early");
|
||||
|
||||
// A preview renders a draft of every third frame first; the frames
|
||||
// are the same whichever order they come in.
|
||||
const auto order = effector::draftFirstOrder(8, 3);
|
||||
check(order == std::vector<int>({0, 3, 6, 1, 2, 4, 5, 7}),
|
||||
"a draft order takes every third frame first, then the rest");
|
||||
std::vector<effector::Image> drafted(8);
|
||||
const int draftHanded = effector::renderFrames(
|
||||
chain, sources, 80, 48, 21, {}, video,
|
||||
[&](int index, effector::Image&& frame) {
|
||||
drafted[static_cast<std::size_t>(index)] = std::move(frame);
|
||||
return true;
|
||||
},
|
||||
order);
|
||||
bool sameClip = draftHanded == 8;
|
||||
for (std::size_t i = 0; sameClip && i < first.size(); ++i) {
|
||||
sameClip = sameBytes(first[i], drafted[i]);
|
||||
}
|
||||
check(sameClip, "a clip rendered draft first holds exactly the same frames");
|
||||
|
||||
chain[1].enabled = false;
|
||||
std::vector<effector::Image> skipped;
|
||||
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||
@@ -1862,9 +2017,277 @@ int main() {
|
||||
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
|
||||
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
|
||||
|
||||
// Tile swap builds its frames out of the sources rather than
|
||||
// transforming a crop of one.
|
||||
{
|
||||
const auto swap = effectByName("tileswap");
|
||||
check(swap->video() && swap->needsSources(),
|
||||
"tile swap is a video effect built from the sources");
|
||||
ChainStage swapStage = stage("tileswap", {{"tileswap_width", 16L},
|
||||
{"tileswap_height", 12L},
|
||||
{"tileswap_rate", 2.0}});
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
check(chainNeedsSources({swapStage}, true), "and a clip led by it needs sources");
|
||||
effector::Rng withSwap(3);
|
||||
effector::Rng withoutSwap(3);
|
||||
check(sameBytes(renderChain({swapStage, scanStage}, sources, 80, 48, withSwap),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutSwap)),
|
||||
"a still skips tile swap as though it were not there");
|
||||
bool refused = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({swapStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||
} catch (const EffectError&) {
|
||||
refused = true;
|
||||
}
|
||||
check(refused, "tile swap with no sources is refused");
|
||||
|
||||
const std::vector<effector::Image> photos = {solid(60, 40, 255, 0, 0),
|
||||
solid(50, 30, 0, 0, 255)};
|
||||
auto frameOf = [&](const ChainStage& at, int index, int count = 8) {
|
||||
effector::Rng rng(11);
|
||||
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, count, 8});
|
||||
};
|
||||
effector::Rng swapA(9);
|
||||
effector::Rng swapB(9);
|
||||
swap->renderFrame(photos, 80, 48, swapA, swapStage.values, FrameTime{3, 8, 8});
|
||||
swap->renderFrame(photos, 80, 48, swapB, swapStage.values, FrameTime{5, 8, 8});
|
||||
check(swapA.next() == swapB.next(), "tile swap leaves the shared rng in one state");
|
||||
|
||||
auto onlyPhotos = [](const effector::Image& image) {
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
const std::uint8_t* row = image.row(y);
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const std::uint8_t* p = row + static_cast<std::size_t>(x) * 3;
|
||||
const bool red = p[0] == 255 && p[1] == 0 && p[2] == 0;
|
||||
const bool blue = p[0] == 0 && p[1] == 0 && p[2] == 255;
|
||||
if (!red && !blue) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
check(onlyPhotos(frameOf(swapStage, 0)) && onlyPhotos(frameOf(swapStage, 5)),
|
||||
"its tiles are cut from the photographs");
|
||||
bool swapped = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
swapped = swapped || !sameBytes(frameOf(swapStage, 0), frameOf(swapStage, index));
|
||||
}
|
||||
check(swapped, "its tiles change over the clip");
|
||||
check(sameBytes(frameOf(swapStage, 0), frameOf(swapStage, 8)),
|
||||
"and the frame after the last is the first again");
|
||||
ChainStage frozenSwap = swapStage;
|
||||
frozenSwap.values["tileswap_rate"] = 0.0;
|
||||
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
|
||||
"at a rate of 0 it holds still");
|
||||
ChainStage scattered = swapStage;
|
||||
scattered.values["tileswap_layout"] = std::string("scatter");
|
||||
ChainStage bricks = swapStage;
|
||||
bricks.values["tileswap_layout"] = std::string("brick");
|
||||
bricks.values["tileswap_keep_order"] = true;
|
||||
check(onlyPhotos(frameOf(scattered, 2)) && onlyPhotos(frameOf(bricks, 6)),
|
||||
"scattered and brick layouts are cut from the photographs too");
|
||||
|
||||
const std::vector<ChainStage> swapChain = {swapStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(swapChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool alone = clip.size() == 8;
|
||||
for (const int index : {0, 4, 7}) {
|
||||
effector::Rng rng(21);
|
||||
alone = alone && sameBytes(effector::renderChain(swapChain, sources, 80, 48, rng,
|
||||
{}, FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// Strip roll builds its frames out of the sources too, and rolls the
|
||||
// picture in each strip through its image.
|
||||
{
|
||||
const auto roll = effectByName("striproll");
|
||||
check(roll->video() && roll->needsSources(),
|
||||
"strip roll is a video effect built from the sources");
|
||||
ChainStage rollStage = stage("striproll", {{"roll_height", 6L}, {"roll_speed", 40L}});
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
effector::Rng withRoll(3);
|
||||
effector::Rng withoutRoll(3);
|
||||
check(sameBytes(renderChain({rollStage, scanStage}, sources, 80, 48, withRoll),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutRoll)),
|
||||
"a still skips strip roll as though it were not there");
|
||||
bool refused = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({rollStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||
} catch (const EffectError&) {
|
||||
refused = true;
|
||||
}
|
||||
check(refused, "strip roll with no sources is refused");
|
||||
|
||||
const std::vector<effector::Image> photos = {gradient(60, 40, 20), gradient(50, 30, 150)};
|
||||
auto frameOf = [&](const ChainStage& at, int index) {
|
||||
effector::Rng rng(11);
|
||||
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, 8, 8});
|
||||
};
|
||||
effector::Rng rollA(9);
|
||||
effector::Rng rollB(9);
|
||||
roll->renderFrame(photos, 80, 48, rollA, rollStage.values, FrameTime{3, 8, 8});
|
||||
roll->renderFrame(photos, 80, 48, rollB, rollStage.values, FrameTime{5, 8, 8});
|
||||
check(rollA.next() == rollB.next(), "strip roll leaves the shared rng in one state");
|
||||
|
||||
// Every row of a strip is a row of one photograph, turned round.
|
||||
auto rowsOfPhotos = [&](const effector::Image& image) {
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
bool found = false;
|
||||
for (const auto& photo : photos) {
|
||||
for (int py = 0; !found && py < photo.height(); ++py) {
|
||||
for (int shift = 0; !found && shift < photo.width(); ++shift) {
|
||||
bool same = true;
|
||||
for (int x = 0; same && x < image.width(); ++x) {
|
||||
const int px = (x + shift) % photo.width();
|
||||
same = std::equal(image.row(y) + x * 3, image.row(y) + x * 3 + 3,
|
||||
photo.row(py) + px * 3);
|
||||
}
|
||||
found = same;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
check(rowsOfPhotos(frameOf(rollStage, 0)) && rowsOfPhotos(frameOf(rollStage, 5)),
|
||||
"its strips are rows of the photographs");
|
||||
bool rolled = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
rolled = rolled || !sameBytes(frameOf(rollStage, 0), frameOf(rollStage, index));
|
||||
}
|
||||
check(rolled, "its strips roll over the clip");
|
||||
check(sameBytes(frameOf(rollStage, 0), frameOf(rollStage, 8)),
|
||||
"and the frame after the last is the first again");
|
||||
ChainStage sideways = rollStage;
|
||||
sideways.values["roll_axis"] = std::string("horizontal");
|
||||
sideways.values["roll_direction"] = std::string("random");
|
||||
sideways.values["roll_keep_order"] = true;
|
||||
check(rowsOfPhotos(frameOf(sideways, 3)) &&
|
||||
!sameBytes(frameOf(sideways, 0), frameOf(sideways, 3)) &&
|
||||
sameBytes(frameOf(sideways, 0), frameOf(sideways, 8)),
|
||||
"sideways, its strips slide along their rows and loop");
|
||||
ChainStage frozenRoll = rollStage;
|
||||
frozenRoll.values["roll_speed"] = 0L;
|
||||
check(sameBytes(frameOf(frozenRoll, 0), frameOf(frozenRoll, 5)),
|
||||
"at a speed of 0 it holds still");
|
||||
|
||||
const std::vector<ChainStage> rollChain = {rollStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(rollChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool alone = clip.size() == 8;
|
||||
for (const int index : {0, 4, 7}) {
|
||||
effector::Rng rng(21);
|
||||
alone = alone && sameBytes(effector::renderChain(rollChain, sources, 80, 48, rng,
|
||||
{}, FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(alone, "a strip roll clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// The video transforms made from image effects. Each gets a setting
|
||||
// that moves plenty, one without its per-frame noise (which, drawn
|
||||
// anew on every frame, is the one thing that does not loop), and one
|
||||
// that switches it off.
|
||||
struct Moving {
|
||||
std::string name;
|
||||
effector::ParamValues lively;
|
||||
effector::ParamValues smooth;
|
||||
effector::ParamValues off;
|
||||
};
|
||||
const std::vector<Moving> moving = {
|
||||
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
|
||||
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
|
||||
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
|
||||
{{"sweep_line_chance", 0.0}}},
|
||||
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
|
||||
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||
{"gate_scratches", 0L}},
|
||||
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||
{"gate_scratches", 0L}}},
|
||||
{"datamosh", {{"mosh_rate", 0.4}, {"mosh_block", 8L}, {"mosh_bleed", true}}, {},
|
||||
{{"mosh_rate", 0.0}}},
|
||||
{"focuspull", {{"focus_radius", 5L}}, {}, {{"focus_radius", 0L}}},
|
||||
{"focuspull", {{"focus_mode", std::string("band")}, {"focus_speed", 1.0}}, {},
|
||||
{{"focus_mode", std::string("band")}, {"focus_radius", 0L}}},
|
||||
};
|
||||
for (const auto& [name, lively, smooth, off] : moving) {
|
||||
const auto effect = effectByName(name);
|
||||
auto with = [&](const effector::ParamValues& changes) {
|
||||
ChainStage built = stage(name);
|
||||
for (const auto& [key, value] : changes) built.values[key] = value;
|
||||
return built;
|
||||
};
|
||||
auto frameOf = [&](const ChainStage& at, int index) {
|
||||
effector::Rng rng(6);
|
||||
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
|
||||
};
|
||||
const ChainStage wildStage = with(lively);
|
||||
check(effect->video() && !effect->needsSources(),
|
||||
name + " is a video effect that needs no sources");
|
||||
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
effector::Rng withIt(3);
|
||||
effector::Rng withoutIt(3);
|
||||
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutIt)),
|
||||
"a still skips " + name + " as though it were not there");
|
||||
|
||||
effector::Rng stateA(9);
|
||||
effector::Rng stateB(9);
|
||||
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
|
||||
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
|
||||
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
|
||||
|
||||
bool changes = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
|
||||
}
|
||||
check(changes, name + " moves over the clip");
|
||||
const ChainStage smoothStage = with(smooth);
|
||||
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
|
||||
name + " loops: the frame after the last is the first again");
|
||||
check(sameBytes(frameOf(with(off), 5), input[0]),
|
||||
name + " switched off leaves its input alone");
|
||||
effector::Rng sliverA(2);
|
||||
effector::Rng sliverB(2);
|
||||
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
|
||||
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
|
||||
name + " copes with a sliver of a canvas");
|
||||
|
||||
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool exactFrames = clip.size() == 8;
|
||||
for (const int index : {0, 5, 7}) {
|
||||
effector::Rng rng(21);
|
||||
exactFrames = exactFrames &&
|
||||
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
|
||||
FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(exactFrames, "a " + name + " clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// The project file: a video says so, a still says nothing about time.
|
||||
effector::Project project;
|
||||
project.stages = {stage("channelsplit", wild)};
|
||||
project.stages = {stage("tracking", wild)};
|
||||
const auto stillJson = effector::projectToJson(project);
|
||||
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
|
||||
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
|
||||
@@ -1877,12 +2300,22 @@ int main() {
|
||||
back.video.preset == "instagram-story",
|
||||
"a video project keeps its length, rate and preset");
|
||||
const auto motion = back.stages.at(0).values;
|
||||
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
|
||||
effector::paramInt(motion, "split_roll", 0) == 60,
|
||||
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
|
||||
effector::paramInt(motion, "tracking_bar", 0) == 60,
|
||||
"and its stages keep their motion");
|
||||
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
||||
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
||||
"an unknown preset reads as custom");
|
||||
check(effector::projectToJson(project).find("\"quality\"") == std::string::npos,
|
||||
"the standard quality is not written");
|
||||
project.video.quality = "small";
|
||||
check(effector::projectFromJson(effector::projectToJson(project)).video.quality == "small",
|
||||
"another quality is kept");
|
||||
check(effector::projectFromJson(base + "2, \"quality\": \"lossless\"}}").video.quality.empty(),
|
||||
"an unknown quality reads as the standard one");
|
||||
check(effector::videoQualityOrDefault("").key == effector::kDefaultVideoQuality &&
|
||||
effector::videoQualityByKey(effector::kDefaultVideoQuality) != nullptr,
|
||||
"an empty quality is the standard one, which the table holds");
|
||||
bool rejected = false;
|
||||
try {
|
||||
effector::projectFromJson(base + "2, \"fps\": 500}}");
|
||||
@@ -1907,6 +2340,9 @@ int main() {
|
||||
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
|
||||
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
|
||||
"a bad rate, length or preset is refused");
|
||||
const auto quality = parsedOptions({"--duration", "2", "--video-quality", "high"});
|
||||
check(quality && quality->video.quality == "high", "--video-quality picks the encoding");
|
||||
check(!parsedOptions({"--video-quality", "best"}), "and refuses one it does not know");
|
||||
check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
|
||||
|
||||
bool wrongName = false;
|
||||
@@ -1929,6 +2365,18 @@ int main() {
|
||||
}
|
||||
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
|
||||
"ffmpeg writes an .mp4 of odd size");
|
||||
// Every quality, through every codec.
|
||||
bool everyQuality = true;
|
||||
for (const auto& quality : effector::videoQualities()) {
|
||||
for (const char* extension : {".mp4", ".webm", ".gif"}) {
|
||||
const auto encoded = (dir / (quality.key + extension)).string();
|
||||
effector::VideoWriter writer(encoded, 16, 16, 12, quality.key);
|
||||
for (int i = 0; i < 2; ++i) writer.write(gradient(16, 16, 60 * i));
|
||||
writer.finish();
|
||||
everyQuality = everyQuality && std::filesystem::file_size(encoded) > 0;
|
||||
}
|
||||
}
|
||||
check(everyQuality, "every quality writes .mp4, .webm and .gif");
|
||||
const auto dropped = (dir / "dropped.webm").string();
|
||||
{
|
||||
effector::VideoWriter writer(dropped, 16, 16, 12);
|
||||
|
||||
+296
-14
@@ -14,6 +14,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/memory.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/version.hpp"
|
||||
@@ -107,6 +109,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Whether any label under `root` says `text`, shown or not.
|
||||
bool showsText(Gtk::Widget& root, const std::string& text) {
|
||||
for (auto* label : all<Gtk::Label>(root)) {
|
||||
if (label->get_text() == text) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -151,6 +161,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
|
||||
|
||||
void runTests(Gtk::Window& window) {
|
||||
const auto& effects = effector::effects();
|
||||
// What can be added to the still project the window opens on.
|
||||
std::vector<effector::EffectPtr> imageEffects;
|
||||
for (const auto& effect : effects) {
|
||||
if (!effect->video()) imageEffects.push_back(effect);
|
||||
}
|
||||
|
||||
std::cout << "sidebar\n";
|
||||
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
|
||||
@@ -193,6 +208,19 @@ void runTests(Gtk::Window& window) {
|
||||
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");
|
||||
bool offered = true;
|
||||
bool videoLast = true;
|
||||
bool seenVideo = false;
|
||||
for (const auto& effect : effects) {
|
||||
auto* entry = menuEntry(*menu, effect->title());
|
||||
offered = offered && entry->get_sensitive() != effect->video();
|
||||
videoLast = videoLast && (effect->video() || !seenVideo);
|
||||
seenVideo = seenVideo || effect->video();
|
||||
}
|
||||
check(seenVideo, "there is a video effect");
|
||||
check(videoLast && showsText(*menu, "Image effects") && showsText(*menu, "Video effects"),
|
||||
"the menu lists image effects and video effects under headings of their own");
|
||||
check(offered, "in a still project only the image effects can be picked");
|
||||
|
||||
add->popup();
|
||||
pump();
|
||||
@@ -202,12 +230,21 @@ void runTests(Gtk::Window& window) {
|
||||
// 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.
|
||||
// Image and video effects scroll apart, so the video effects never
|
||||
// scroll out of sight below a long list of image effects.
|
||||
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");
|
||||
int cap = 0;
|
||||
for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
|
||||
bool scrolled = scrollers.size() == 2;
|
||||
for (const auto& effect : effects) {
|
||||
scrolled = scrolled &&
|
||||
isAncestor(*scrollers[effect->video() ? 1 : 0], *menuEntry(*menu, effect->title()));
|
||||
}
|
||||
check(scrolled, "the image and the video entries scroll, each on their own");
|
||||
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
|
||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
|
||||
// The rest is the two headings, the separator and, in a still, the hint
|
||||
// under the video heading.
|
||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 170,
|
||||
"and the menu stays within it however many effects there are");
|
||||
add->popdown();
|
||||
pump();
|
||||
@@ -304,15 +341,19 @@ void runTests(Gtk::Window& window) {
|
||||
}
|
||||
|
||||
std::cout << "many stages\n";
|
||||
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.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());
|
||||
for (int i = 4; i < 12; ++i) {
|
||||
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.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");
|
||||
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
|
||||
"including for the last image effect in the menu");
|
||||
pump();
|
||||
const int longSplit = split != nullptr ? split->get_position() : -1;
|
||||
check(longSplit > fewSplit && listScroller != nullptr &&
|
||||
@@ -453,7 +494,9 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto path = dir / "moving.json";
|
||||
effector::Project project;
|
||||
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources
|
||||
// Neither needs sources: an image effect, then the video effect.
|
||||
project.stages.push_back({effector::effectByName("channelsplit"), {}});
|
||||
project.stages.push_back({effector::effectByName("tracking"), {}});
|
||||
effector::saveProject(path.string(), project);
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
@@ -466,25 +509,57 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
|
||||
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
|
||||
auto* playback = named<Gtk::Box>(window, "playback");
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
|
||||
height != nullptr && playback != nullptr,
|
||||
"the video switch, its settings and the playback bar are there");
|
||||
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
|
||||
height == nullptr || playback == nullptr) {
|
||||
height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
|
||||
return;
|
||||
}
|
||||
const std::string tracking = effector::effectByName("tracking")->title();
|
||||
auto* entry = menuEntry(*add->get_popover(), tracking);
|
||||
// The label of a chain row, which says whether the stage runs.
|
||||
auto rowLabel = [&](int index) -> Gtk::Label* {
|
||||
auto* row = chainList->get_row_at_index(index);
|
||||
if (row == nullptr) return nullptr;
|
||||
for (auto* label : all<Gtk::Label>(*row)) {
|
||||
if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
auto* videoRow = chainList->get_row_at_index(1);
|
||||
auto* videoLabel = rowLabel(1);
|
||||
|
||||
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
|
||||
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
|
||||
"a still project shows no video settings and no playback");
|
||||
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split");
|
||||
check(videoRow != nullptr && !all<Gtk::Image>(*videoRow).empty() && videoLabel != nullptr &&
|
||||
videoLabel->has_css_class("dim-label") &&
|
||||
videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture",
|
||||
"a video stage is marked as one, and dimmed as skipped in a still");
|
||||
check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs");
|
||||
check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still");
|
||||
|
||||
toggle->set_active(true);
|
||||
pump(200);
|
||||
videoLabel = rowLabel(1);
|
||||
check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"),
|
||||
"in a video the video stage runs");
|
||||
check(entry != nullptr && entry->get_sensitive(), "and more can be added");
|
||||
chainList->select_row(*chainList->get_row_at_index(1));
|
||||
pump(100);
|
||||
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
|
||||
"a video shows the channel split's motion params");
|
||||
"its settings are its motion");
|
||||
stack->set_visible_child("setup");
|
||||
pump(100);
|
||||
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
||||
auto* quality = named<Gtk::DropDown>(window, "video-quality");
|
||||
check(quality != nullptr && quality->is_drawable() &&
|
||||
effector::videoQualities().at(quality->get_selected()).key ==
|
||||
effector::kDefaultVideoQuality,
|
||||
"and its file quality, standard to begin with");
|
||||
|
||||
preset->set_selected(1); // Instagram story, the first preset
|
||||
pump(200);
|
||||
@@ -509,11 +584,215 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
|
||||
toggle->set_active(false);
|
||||
pump(300);
|
||||
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
|
||||
"switched back to a still, the playback and motion params go");
|
||||
videoLabel = rowLabel(1);
|
||||
check(!playback->get_visible() && videoLabel != nullptr &&
|
||||
videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(),
|
||||
"switched back to a still, the playback goes and the video stage is skipped again");
|
||||
stack->set_visible_child("chain");
|
||||
}
|
||||
|
||||
// A font param is a font chooser showing the family, and the family picked
|
||||
// there is what the stage renders with and what the project saves.
|
||||
void testFont(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "font\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto path = dir / "lettering.json";
|
||||
effector::Project project;
|
||||
effector::ChainStage stage{effector::effectByName("text"), {}};
|
||||
stage.values["font_family"] = std::string("Serif");
|
||||
project.stages.push_back(stage);
|
||||
effector::saveProject(path.string(), project);
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(300);
|
||||
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) {
|
||||
chainList->select_row(*chainList->get_row_at_index(0));
|
||||
}
|
||||
pump(100);
|
||||
std::vector<Gtk::FontDialogButton*> buttons;
|
||||
collect<Gtk::FontDialogButton>(window, [](Gtk::FontDialogButton& b) { return b.is_drawable(); },
|
||||
buttons);
|
||||
check(buttons.size() == 1, "the text stage picks its font with a font chooser");
|
||||
if (buttons.size() != 1) return;
|
||||
auto* button = buttons.front();
|
||||
check(button->get_level() == Gtk::FontLevel::FAMILY,
|
||||
"which chooses only the family, the weight and size being params of their own");
|
||||
check(button->get_font_desc().get_family() == "Serif", "and shows the family saved");
|
||||
|
||||
Pango::FontDescription mono;
|
||||
mono.set_family("Monospace");
|
||||
button->set_font_desc(mono);
|
||||
pump(100);
|
||||
window.activate_action("win.save-project");
|
||||
pump(100);
|
||||
const auto saved = effector::loadProject(path.string());
|
||||
check(saved.stages.size() == 1 &&
|
||||
effector::paramText(saved.stages[0].values, "font_family", "") == "Monospace",
|
||||
"a family chosen there is saved with the project");
|
||||
}
|
||||
|
||||
bool actionEnabled(Gtk::Window& window, const std::string& name) {
|
||||
auto action = dynamic_cast<Gtk::ApplicationWindow&>(window).lookup_action(name);
|
||||
return action && action->get_enabled();
|
||||
}
|
||||
|
||||
// Whether the window catches `keys` for `action` before the focused widget.
|
||||
bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) {
|
||||
auto controllers = window.observe_controllers();
|
||||
for (guint i = 0; i < controllers->get_n_items(); ++i) {
|
||||
auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i));
|
||||
const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE;
|
||||
if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) {
|
||||
auto* list = G_LIST_MODEL(controller);
|
||||
for (guint j = 0; j < g_list_model_get_n_items(list); ++j) {
|
||||
auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j));
|
||||
char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut));
|
||||
auto* named = gtk_shortcut_get_action(shortcut);
|
||||
const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) &&
|
||||
action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named));
|
||||
g_free(trigger);
|
||||
g_object_unref(shortcut);
|
||||
if (match) {
|
||||
g_object_unref(controller);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_object_unref(controller);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void testHistory(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "undo and redo\n";
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||
if (chainList == nullptr || add == nullptr) return;
|
||||
|
||||
check(capturesShortcut(window, "<Control>z", "win.undo") &&
|
||||
capturesShortcut(window, "<Control>y", "win.redo") &&
|
||||
capturesShortcut(window, "<Shift><Control>z", "win.redo"),
|
||||
"Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget");
|
||||
|
||||
window.activate_action("win.new-project");
|
||||
pump(700);
|
||||
check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"),
|
||||
"a new project has nothing to undo or redo");
|
||||
|
||||
addFromMenu(*add, "Blur");
|
||||
pump(700);
|
||||
addFromMenu(*add, "Scanlines");
|
||||
pump(700);
|
||||
check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added");
|
||||
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
|
||||
"undo takes the last stage off");
|
||||
check(actionEnabled(window, "redo"), "and it can be redone");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain");
|
||||
|
||||
window.activate_action("win.redo");
|
||||
pump();
|
||||
window.activate_action("win.redo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" &&
|
||||
!actionEnabled(window, "redo"),
|
||||
"redo puts both back");
|
||||
|
||||
// Undone straight away, before the edit has settled into a step.
|
||||
addFromMenu(*add, "Blur");
|
||||
check(actionEnabled(window, "undo"), "an edit can be undone at once");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 2, "and undoing it takes that edit back");
|
||||
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
addFromMenu(*add, "Blur");
|
||||
pump(700);
|
||||
check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone");
|
||||
|
||||
// The images of an opened project arrive after it, from the decoder, and
|
||||
// are part of it rather than a step to undo.
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto source = dir / "source.png";
|
||||
effector::Image(64, 64).save(source.string());
|
||||
effector::Project project;
|
||||
project.sources.push_back(source.string());
|
||||
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||
const auto path = dir / "history.json";
|
||||
effector::saveProject(path.string(), project);
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(1000);
|
||||
auto* sources = named<Gtk::Box>(window, "sources");
|
||||
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||
auto* sourceList = lists.empty() ? nullptr : lists.front();
|
||||
check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"),
|
||||
"opening a project with images leaves nothing to undo");
|
||||
if (sourceList == nullptr || rowCount(*sourceList) != 1) return;
|
||||
|
||||
// Removing an image is undone with the image still in hand.
|
||||
auto* stack = all<Gtk::Stack>(window).front();
|
||||
stack->set_visible_child("setup");
|
||||
pump();
|
||||
const auto removes = all<Gtk::Button>(*sourceList->get_row_at_index(0));
|
||||
if (!removes.empty()) press(*removes.front());
|
||||
pump(700);
|
||||
check(rowCount(*sourceList) == 0, "an image removed");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*sourceList) == 1, "comes back with undo");
|
||||
stack->set_visible_child("chain");
|
||||
|
||||
window.activate_action("win.new-project");
|
||||
pump(700);
|
||||
}
|
||||
|
||||
// A project whose images will not fit is refused before any is decoded, and
|
||||
// saving it then must not quietly drop them from the file.
|
||||
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "memory guard\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
effector::Project project;
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const auto source = dir / ("big" + std::to_string(i) + ".png");
|
||||
effector::Image(600, 600).save(source.string());
|
||||
project.sources.push_back(source.string());
|
||||
}
|
||||
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||
const auto path = dir / "big.json";
|
||||
effector::saveProject(path.string(), project);
|
||||
|
||||
effector::limitAvailableMemory(std::size_t{1} << 20); // 2.1 MB of images, 1 MB free
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(500);
|
||||
auto* sources = named<Gtk::Widget>(window, "sources");
|
||||
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||
check(!lists.empty() && rowCount(*lists.front()) == 0, "images too large for the memory are not loaded");
|
||||
bool told = false;
|
||||
for (auto* label : all<Gtk::Label>(window)) {
|
||||
const std::string text = label->get_text();
|
||||
if (label->is_drawable() && text.find("Loading these 2 images needs about") != std::string::npos &&
|
||||
text.find("stay in the project") != std::string::npos) {
|
||||
told = true;
|
||||
}
|
||||
}
|
||||
check(told, "and the window says why");
|
||||
|
||||
window.activate_action("win.save-project");
|
||||
pump(100);
|
||||
check(effector::loadProject(path.string()).sources.size() == 2,
|
||||
"saving the project keeps the images it could not load");
|
||||
effector::limitAvailableMemory(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
@@ -542,6 +821,9 @@ int main(int argc, char** argv) {
|
||||
testHelpWindows(*window);
|
||||
testRecentProjects(*window, dataDir / "projects");
|
||||
testVideo(*window, dataDir / "video");
|
||||
testFont(*window, dataDir / "font");
|
||||
testHistory(*window, dataDir / "history");
|
||||
testMemoryGuard(*window, dataDir / "memory");
|
||||
app->remove_window(*window);
|
||||
});
|
||||
const int status = app->run(argc > 0 ? 1 : 0, argv);
|
||||
|
||||
Reference in New Issue
Block a user