Split effects into image and video effects, with VHS tracking as the first video effect
Effect::video() says which kind an effect is. The Add effect menu lists the two kinds in sections of their own, each scrolling on its own, and offers video effects only in a video project; a video stage carries a film icon in the chain and, in a still, is dimmed and skipped like a stage switched off. enabledStages takes whether the render is a video and is the one place that leaves video stages out of a still. --help groups the video effects under a heading of their own, --list-effects marks them, and a video effect given for a still is skipped with a warning. VideoEffect is the base every video effect builds on, and keeps the rules a moving stage has to follow: it takes one value from the shared rng, the same on every frame, and hands the effect steady and per-frame generators and loop-rounded time through a Moment, so a subclass writes only animate(). VHS tracking is the first, and the one to copy: the colour planes sway and shake and a tracking bar rolls down the picture. That motion moves out of channel split, which is an image effect again and renders stills exactly as before; forVideo() goes with it. Version 0.7.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,26 @@ 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
|
`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.
|
commit that made it. The interface shows this file under Changelog in its menu.
|
||||||
|
|
||||||
|
## [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
|
## [0.6.1] - 2026-09-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -95,7 +95,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
|
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".
|
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
|
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
|
`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.
|
CLI, in the chain menu and as a cascade stage on its own.
|
||||||
@@ -181,22 +182,39 @@ 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
|
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.
|
duration and fps. Adding a preset is a line there.
|
||||||
|
|
||||||
Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default,
|
Effects are of two kinds, and `Effect::video()` says which. An *image effect*
|
||||||
`fps == 0`, is a still) through `Effect::renderFrame`, which falls back to
|
(the default) makes a picture and holds still in a clip; it overrides only
|
||||||
`render` — an effect that does not move overrides nothing. One that moves
|
`render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
|
||||||
overrides `renderFrame` and `animated(values)`, and marks its motion params
|
and nothing else. `enabledStages(stages, video)` is where the kinds meet the
|
||||||
`.forVideo()`: the interface hides those in a still project (declaratively,
|
chain: in a still, a video stage is left out exactly like a disabled one,
|
||||||
like `.styled()`), the CLI still has their flags. A still must render byte for
|
drawing nothing from the rng. So `renderChain` (by `frame.video()`),
|
||||||
byte what it did before the effect learned to move.
|
`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 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.
|
||||||
|
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
|
`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
|
`Rng(seed)`, so an image effect draws the same values every frame and holds
|
||||||
and holds still. A moving effect must therefore leave the shared rng in the
|
still, and because `VideoEffect` leaves the shared rng in one state, the stages
|
||||||
same state on every frame — the channel split draws its per-frame shake from a
|
after a video effect hold still too. The stages before the first video effect
|
||||||
generator of its own, seeded by one draw — or everything after it would flicker.
|
are rendered once and their image and rng state reused. The `video` tests
|
||||||
The stages before the first `animated()` one are rendered once and their image
|
assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
|
||||||
and rng state reused; the `video` tests assert that frame k is exactly
|
shortcut can never change output.
|
||||||
`renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output.
|
|
||||||
The determinism contract covers the frames; the encoded file is whatever the
|
The determinism contract covers the frames; the encoded file is whatever the
|
||||||
machine's ffmpeg makes of them.
|
machine's ffmpeg makes of them.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(effector VERSION 0.6.1 LANGUAGES CXX)
|
project(effector VERSION 0.7.0 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -56,6 +56,8 @@ add_library(effector_core STATIC
|
|||||||
src/effects/slitshift.cpp
|
src/effects/slitshift.cpp
|
||||||
src/effects/text.cpp
|
src/effects/text.cpp
|
||||||
src/effects/tiles.cpp
|
src/effects/tiles.cpp
|
||||||
|
src/effects/tracking.cpp
|
||||||
|
src/effects/video_effect.cpp
|
||||||
src/effects/warp.cpp
|
src/effects/warp.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
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.
|
palette GIF.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 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 channelsplit 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
|
`--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
|
whatever was not given alongside it.
|
||||||
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
|
Effects come in two kinds. **Image effects** make a picture and hold still in a
|
||||||
picture, like a worn tape on an old tube. Its motion params only show in a
|
clip. **Video effects** are motion: the Add effect menu lists them under a
|
||||||
video project, and a still renders exactly as before. Every frame starts from
|
heading of their own, and they can only be added once the project is a video.
|
||||||
the same seed, so stages that do not move hold still while one that does
|
In a still project a video stage is marked as skipped and the render leaves it
|
||||||
moves, and a clip renders frame for frame the same again.
|
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
|
Any option can also be passed when launching the interface; it becomes the
|
||||||
starting state (images, canvas size, effect settings, seed).
|
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
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
# test checks it). From this folder: makepkg -si
|
# test checks it). From this folder: makepkg -si
|
||||||
pkgname=effector
|
pkgname=effector
|
||||||
pkgver=0.6.1
|
pkgver=0.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
+10
-3
@@ -264,15 +264,22 @@ std::string usage(const char* programName) {
|
|||||||
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
|
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\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()) {
|
for (const auto& effect : effects()) {
|
||||||
out << "\n" << effect->title() << " options:\n";
|
if (effect->video() != video) continue;
|
||||||
|
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
|
||||||
for (const auto& spec : effect->params()) {
|
for (const auto& spec : effect->params()) {
|
||||||
std::string flag = spec.flag();
|
std::string flag = spec.flag();
|
||||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||||
out << " " << flag;
|
out << " " << flag;
|
||||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
||||||
out << " " << (spec.help.empty() ? spec.label : spec.help)
|
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
||||||
<< (spec.videoOnly ? " (video only)" : "") << "\n";
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.str();
|
return out.str();
|
||||||
|
|||||||
+29
-29
@@ -6,11 +6,13 @@
|
|||||||
|
|
||||||
namespace effector {
|
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;
|
std::vector<const ChainStage*> running;
|
||||||
running.reserve(stages.size());
|
running.reserve(stages.size());
|
||||||
for (const auto& stage : stages) {
|
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;
|
return running;
|
||||||
}
|
}
|
||||||
@@ -36,7 +38,7 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// The stages that run, checked: throws for a chain with nothing to render.
|
// 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.");
|
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||||
for (const auto& stage : stages) {
|
for (const auto& stage : stages) {
|
||||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
@@ -44,9 +46,17 @@ std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stag
|
|||||||
|
|
||||||
// A disabled stage never reaches the render at all: it is not handed an
|
// 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
|
// image and draws nothing from the shared rng, so switching one off gives
|
||||||
// back exactly the picture the chain made before it was added.
|
// back exactly the picture the chain made before it was added. A video
|
||||||
auto running = enabledStages(stages);
|
// effect in a still is left out the same way.
|
||||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
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;
|
return running;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,37 +109,27 @@ Image runStages(const std::vector<const ChainStage*>& running, std::size_t first
|
|||||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
int width, int height, Rng& rng, const StyleSet& styles,
|
int width, int height, Rng& rng, const StyleSet& styles,
|
||||||
const FrameTime& frame) {
|
const FrameTime& frame) {
|
||||||
const auto running = runningStages(stages);
|
const auto running = runningStages(stages, frame.video());
|
||||||
ensureRenderFits(running, width, height, styles);
|
ensureRenderFits(running, width, height, styles);
|
||||||
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
|
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
|
||||||
frame);
|
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 renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||||
const VideoSettings& video,
|
const VideoSettings& video,
|
||||||
const std::function<bool(int index, Image&& frame)>& onFrame) {
|
const std::function<bool(int index, Image&& frame)>& onFrame) {
|
||||||
const auto running = runningStages(stages);
|
const auto running = runningStages(stages, true);
|
||||||
ensureRenderFits(running, width, height, styles);
|
ensureRenderFits(running, width, height, styles);
|
||||||
const int count = video.frameCount();
|
const int count = video.frameCount();
|
||||||
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
|
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
|
||||||
|
|
||||||
// Everything before the first stage that moves is the same on every
|
// Everything before the first video effect is the same on every frame --
|
||||||
// frame -- the same seed, the same values, no time -- so it is rendered
|
// the same seed, the same values, no time -- so it is rendered once, as
|
||||||
// once, as frame 0, and picked up from there. The rng is copied along
|
// frame 0, and picked up from there. The rng is copied along with the
|
||||||
// with the picture, which is what makes the shortcut exact.
|
// picture, which is what makes the shortcut exact.
|
||||||
std::size_t still = 0;
|
std::size_t still = 0;
|
||||||
while (still < running.size() &&
|
while (still < running.size() && !running[still]->effect->video()) ++still;
|
||||||
!running[still]->effect->animated(stageValues(*running[still], styles))) {
|
|
||||||
++still;
|
|
||||||
}
|
|
||||||
Rng prefixRng(seed);
|
Rng prefixRng(seed);
|
||||||
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
|
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
|
||||||
styles, FrameTime{0, count, fps});
|
styles, FrameTime{0, count, fps});
|
||||||
@@ -146,24 +146,24 @@ int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
|
||||||
const auto running = enabledStages(stages);
|
const auto running = enabledStages(stages, video);
|
||||||
if (running.empty()) return false;
|
if (running.empty()) return false;
|
||||||
return running.front()->effect->needsSources();
|
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;
|
std::string text;
|
||||||
for (const auto* stage : enabledStages(stages)) {
|
for (const auto* stage : enabledStages(stages, video)) {
|
||||||
if (!text.empty()) text += " → ";
|
if (!text.empty()) text += " → ";
|
||||||
text += stage->effect->title();
|
text += stage->effect->title();
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
|
||||||
std::string text;
|
std::string text;
|
||||||
for (const auto* stage : enabledStages(stages)) {
|
for (const auto* stage : enabledStages(stages, video)) {
|
||||||
if (!text.empty()) text += "-";
|
if (!text.empty()) text += "-";
|
||||||
text += stage->effect->name();
|
text += stage->effect->name();
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-15
@@ -40,11 +40,12 @@ struct ChainStage {
|
|||||||
// that style carries. The one place a style is resolved.
|
// that style carries. The one place a style is resolved.
|
||||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||||
|
|
||||||
// The stages that actually run, in order, as pointers into `stages`. A
|
// The stages that actually run, in order, as pointers into `stages`: those
|
||||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
// switched on, and of those the video effects only in a video. A stage left
|
||||||
// renders exactly as it would if that stage had never been added -- this is
|
// out draws nothing from the rng and is handed no image, so a chain renders
|
||||||
// the one place the rest of the code asks what "disabled" means.
|
// exactly as it would if that stage had never been added -- this is the one
|
||||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
// 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;
|
// 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
|
// every later one sees the previous stage's output as its only source, so blur
|
||||||
@@ -55,21 +56,19 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
|||||||
// `styles` is the project's pool; a chain built without one -- the command
|
// `styles` is the project's pool; a chain built without one -- the command
|
||||||
// line's -- passes none and renders from the stages alone.
|
// line's -- passes none and renders from the stages alone.
|
||||||
//
|
//
|
||||||
// `frame` says where in a video the render sits, a still by default; every
|
// `frame` says where in a video the render sits, a still by default, in
|
||||||
// stage is handed it, and only one that moves makes anything of it.
|
// 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,
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
int width, int height, Rng& rng, const StyleSet& styles = {},
|
int width, int height, Rng& rng, const StyleSet& styles = {},
|
||||||
const FrameTime& frame = {});
|
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
|
// Renders every frame of a video, in order, handing each to `onFrame`; it
|
||||||
// returning false stops the render there. Frame k is exactly what
|
// returning false stops the render there. Frame k is exactly what
|
||||||
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} --
|
// 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
|
// every frame starts from the same seed, so a stage that does not move
|
||||||
// renders the same picture throughout rather than reshuffling on every
|
// 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.
|
// their picture and the rng state they leave are reused for every frame.
|
||||||
// Returns the number of frames handed out.
|
// Returns the number of frames handed out.
|
||||||
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
@@ -80,13 +79,13 @@ int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>
|
|||||||
// Whether the chain has to be given source images. Only the first stage that
|
// 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
|
// 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.
|
// 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
|
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||||
// what was rendered, so a disabled stage is left out of them.
|
// what was rendered, so a stage that did not run is left out of them.
|
||||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
|
||||||
|
|
||||||
// "scanlines-blur", for file names.
|
// "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
|
} // namespace effector
|
||||||
|
|||||||
+12
-11
@@ -58,21 +58,22 @@ public:
|
|||||||
return title();
|
return title();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a stage with these values changes from one frame of a video to
|
// Whether this is a video effect rather than an image effect. An image
|
||||||
// the next. A stage that does not is rendered once for the whole clip.
|
// effect makes a picture and holds still in a clip; a video effect is
|
||||||
virtual bool animated(const ParamValues& values) const {
|
// motion and nothing else, so it is offered only in a video project and a
|
||||||
(void)values;
|
// still skips it whole, as it skips a stage that is switched off. Video
|
||||||
return false;
|
// 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,
|
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const = 0;
|
const ParamValues& values) const = 0;
|
||||||
|
|
||||||
// One frame of a video, or a still when `frame` says so. An effect that
|
// One frame of a video, or a still when `frame` says so. An image effect
|
||||||
// does not move needs only render(), which this falls back to. One that
|
// needs only render(), which this falls back to; a video effect renders
|
||||||
// does must leave `rng` in the same state on every frame -- a frame's own
|
// here, and must leave `rng` in the same state on every frame -- a
|
||||||
// randomness comes from a generator of its own -- so the stages after it
|
// frame's own randomness comes from a generator of its own -- so the
|
||||||
// stay put while it moves.
|
// stages after it stay put while it moves.
|
||||||
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values, const FrameTime& frame) const {
|
const ParamValues& values, const FrameTime& frame) const {
|
||||||
(void)frame;
|
(void)frame;
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ ParamSpec ParamSpec::styled() const {
|
|||||||
return copy;
|
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,
|
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help, long step) {
|
long maximum, std::string help, long step) {
|
||||||
ParamSpec spec;
|
ParamSpec spec;
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ struct ParamSpec {
|
|||||||
// Whether a named style carries this param rather than the stage it sits
|
// Whether a named style carries this param rather than the stage it sits
|
||||||
// on: see core/style.hpp.
|
// on: see core/style.hpp.
|
||||||
bool inStyle = false;
|
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"
|
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||||
|
|
||||||
@@ -38,9 +34,6 @@ struct ParamSpec {
|
|||||||
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
||||||
// signatures they have.
|
// signatures they have.
|
||||||
ParamSpec styled() const;
|
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,
|
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help = {}, long step = 1);
|
long maximum, std::string help = {}, long step = 1);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "effects/slitshift.hpp"
|
#include "effects/slitshift.hpp"
|
||||||
#include "effects/text.hpp"
|
#include "effects/text.hpp"
|
||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
#include "effects/tracking.hpp"
|
||||||
#include "effects/warp.hpp"
|
#include "effects/warp.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
@@ -36,6 +37,8 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<WarpEffect>(),
|
std::make_shared<WarpEffect>(),
|
||||||
std::make_shared<BitCrushEffect>(),
|
std::make_shared<BitCrushEffect>(),
|
||||||
std::make_shared<FeedbackEffect>(),
|
std::make_shared<FeedbackEffect>(),
|
||||||
|
// Video effects, offered only in a video project.
|
||||||
|
std::make_shared<TrackingEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cmath>
|
|
||||||
#include <numbers>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/parallel.hpp"
|
#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 "
|
"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 "
|
"green and blue the other, as 3D glasses would; random moves one "
|
||||||
"channel picked at random."),
|
"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;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -57,68 +35,28 @@ struct Offset {
|
|||||||
|
|
||||||
using ChannelOffsets = std::array<Offset, 3>;
|
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{};
|
ChannelOffsets offsets{};
|
||||||
auto draw = [&] {
|
auto draw = [&] {
|
||||||
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
|
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
|
||||||
static_cast<int>(rng.inRange(-amount, amount))};
|
static_cast<int>(rng.inRange(-amount, amount))};
|
||||||
};
|
};
|
||||||
moved = -1;
|
|
||||||
if (channels == "red_cyan") {
|
if (channels == "red_cyan") {
|
||||||
const Offset red = draw();
|
const Offset red = draw();
|
||||||
offsets[0] = red;
|
offsets[0] = red;
|
||||||
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
||||||
} else if (channels == "random") {
|
} else if (channels == "random") {
|
||||||
moved = static_cast<int>(rng.below(2));
|
offsets[rng.below(2)] = draw();
|
||||||
offsets[static_cast<std::size_t>(moved)] = draw();
|
|
||||||
} else {
|
} else {
|
||||||
for (auto& offset : offsets) offset = draw();
|
for (auto& offset : offsets) offset = draw();
|
||||||
}
|
}
|
||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A motion of each channel, handed out the way the mode moves channels: all
|
} // namespace
|
||||||
// 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>;
|
|
||||||
|
|
||||||
Motion spread(const Motion& motion, const std::string& channels, int moved) {
|
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]};
|
const ParamValues& values) const {
|
||||||
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) {
|
|
||||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
|
|
||||||
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
|
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 band = perBand ? bandHeight : height;
|
||||||
const int bandCount = (height + band - 1) / band;
|
const int bandCount = (height + band - 1) / band;
|
||||||
std::vector<ChannelOffsets> plan;
|
std::vector<ChannelOffsets> plan;
|
||||||
std::vector<int> moved;
|
|
||||||
plan.reserve(static_cast<std::size_t>(bandCount));
|
plan.reserve(static_cast<std::size_t>(bandCount));
|
||||||
moved.reserve(static_cast<std::size_t>(bandCount));
|
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image canvas(width, height);
|
Image canvas(width, height);
|
||||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
for (std::size_t y = begin; y < end; ++y) {
|
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));
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
for (int c = 0; c < 3; ++c) {
|
for (int c = 0; c < 3; ++c) {
|
||||||
const Offset& offset = offsets[static_cast<std::size_t>(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 int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
|
||||||
const std::uint8_t* in = input.row(sy);
|
const std::uint8_t* in = input.row(sy);
|
||||||
for (int x = 0; x < width; ++x) {
|
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];
|
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;
|
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
|
} // namespace effector
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ namespace effector {
|
|||||||
|
|
||||||
// Knocks the red, green and blue planes out of register, either the whole image
|
// 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.
|
// 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
|
// 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.
|
// sources loaded it starts from a crop of a randomly chosen one.
|
||||||
class ChannelSplitEffect : public Effect {
|
class ChannelSplitEffect : public Effect {
|
||||||
@@ -21,12 +18,9 @@ public:
|
|||||||
}
|
}
|
||||||
const std::vector<ParamSpec>& params() const override;
|
const std::vector<ParamSpec>& params() const override;
|
||||||
bool needsSources() const override { return false; }
|
bool needsSources() const override { return false; }
|
||||||
bool animated(const ParamValues& values) const override;
|
|
||||||
|
|
||||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const override;
|
const ParamValues& values) const override;
|
||||||
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
|
||||||
const ParamValues& values, const FrameTime& frame) const override;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace effector
|
} // 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,47 @@
|
|||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#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};
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#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 a transform: it needs no source images, and leading a chain it
|
||||||
|
// starts from a crop of one, as the other transforms do.
|
||||||
|
// - 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(); see TrackingEffect for one.
|
||||||
|
class VideoEffect : public Effect {
|
||||||
|
public:
|
||||||
|
bool video() const final { return true; }
|
||||||
|
bool needsSources() const final { 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.
|
||||||
|
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+95
-23
@@ -381,7 +381,7 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
|
|||||||
// rather than left there to be filled in vain. This is the same declarative
|
// rather than left there to be filled in vain. This is the same declarative
|
||||||
// answer the render gate uses -- no effect is named here.
|
// answer the render gate uses -- no effect is named here.
|
||||||
void EffectorWindow::updateSourcesVisible() {
|
void EffectorWindow::updateSourcesVisible() {
|
||||||
sourcesSection_.set_visible(chainNeedsSources(stages_));
|
sourcesSection_.set_visible(chainNeedsSources(stages_, isVideo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The heading carries the count, which a long list scrolled away would hide.
|
// The heading carries the count, which a long list scrolled away would hide.
|
||||||
@@ -608,7 +608,9 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
|
|||||||
suspendVideoSync_ = suspended;
|
suspendVideoSync_ = suspended;
|
||||||
playbackBar_.set_visible(false);
|
playbackBar_.set_visible(false);
|
||||||
clearClip();
|
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() {
|
void EffectorWindow::onVideoToggled() {
|
||||||
@@ -619,6 +621,8 @@ void EffectorWindow::onVideoToggled() {
|
|||||||
result_ = Image();
|
result_ = Image();
|
||||||
clearClip();
|
clearClip();
|
||||||
saveButton_.set_sensitive(false);
|
saveButton_.set_sensitive(false);
|
||||||
|
updateVideoEffectItems();
|
||||||
|
rebuildChainRows(); // a video stage runs or is skipped with the switch
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
@@ -805,8 +809,11 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||||
// The settings of a stage that is switched off stay editable: it is still
|
// 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.
|
// 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() +
|
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;
|
int row = 0;
|
||||||
// The style the stage follows, when its effect takes one at all: the
|
// The style the stage follows, when its effect takes one at all: the
|
||||||
@@ -829,9 +836,6 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& spec : stage.effect->params()) {
|
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
|
// A styled param is read from the style while one is followed, so the
|
||||||
// widgets show what the stage will actually render with.
|
// widgets show what the stage will actually render with.
|
||||||
const bool shared = style != nullptr && spec.inStyle;
|
const bool shared = style != nullptr && spec.inStyle;
|
||||||
@@ -1707,16 +1711,51 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
|
|||||||
return *box;
|
return *box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tallest the menu of effects grows before it scrolls, in pixels.
|
// The tallest each part of the menu of effects grows before it scrolls, in
|
||||||
constexpr int addMenuMaxHeight = 480;
|
// pixels: together, with their headings, short enough to open below a button
|
||||||
|
// near the top of a small screen.
|
||||||
|
constexpr int addMenuImageHeight = 330;
|
||||||
|
constexpr int addMenuVideoHeight = 110;
|
||||||
|
|
||||||
// One entry per registered effect, so a new effect shows up here without this
|
// One entry per registered effect, so a new effect shows up here without this
|
||||||
// file knowing anything about it.
|
// file knowing anything about it. 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() {
|
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||||
auto* popover = Gtk::make_managed<Gtk::Popover>();
|
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 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_) {
|
for (const auto& effect : effects_) {
|
||||||
|
if (effect->video() != video) continue;
|
||||||
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||||||
item->add_css_class("flat");
|
item->add_css_class("flat");
|
||||||
item->set_tooltip_text(effect->description());
|
item->set_tooltip_text(effect->description());
|
||||||
@@ -1725,21 +1764,33 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
|
|||||||
addEffectButton_.popdown();
|
addEffectButton_.popdown();
|
||||||
appendStage(effect);
|
appendStage(effect);
|
||||||
});
|
});
|
||||||
|
if (video) videoEffectItems_.push_back(item);
|
||||||
list->append(*item);
|
list->append(*item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A popover that does not fit on the screen is not shown at all, and the
|
// A popover that does not fit on the screen is not shown at all, and
|
||||||
// list grows with every effect registered, so past a point it scrolls.
|
// the lists grow with every effect registered, so past a point they
|
||||||
|
// scroll.
|
||||||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
scroller->set_propagate_natural_height(true);
|
scroller->set_propagate_natural_height(true);
|
||||||
scroller->set_max_content_height(addMenuMaxHeight);
|
scroller->set_max_content_height(video ? addMenuVideoHeight : addMenuImageHeight);
|
||||||
scroller->set_child(*list);
|
scroller->set_child(*list);
|
||||||
|
sections->append(*scroller);
|
||||||
|
}
|
||||||
|
updateVideoEffectItems();
|
||||||
|
|
||||||
popover->set_child(*scroller);
|
popover->set_child(*sections);
|
||||||
return *popover;
|
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) {
|
void EffectorWindow::appendStage(const EffectPtr& effect) {
|
||||||
stages_.push_back({effect, effect->defaults()});
|
stages_.push_back({effect, effect->defaults()});
|
||||||
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
||||||
@@ -1782,7 +1833,8 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
|
|
||||||
// The first stage that actually runs is the one handed the sources, which
|
// The first stage that actually runs is the one handed the sources, which
|
||||||
// is not stage 1 once stage 1 is switched off.
|
// is not stage 1 once stage 1 is switched off.
|
||||||
const auto running = enabledStages(stages_);
|
const bool video = isVideo();
|
||||||
|
const auto running = enabledStages(stages_, video);
|
||||||
const int firstRunning =
|
const int firstRunning =
|
||||||
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
|
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
|
||||||
|
|
||||||
@@ -1809,13 +1861,28 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
});
|
});
|
||||||
box->append(*check);
|
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));
|
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
|
||||||
stageLabels_.push_back(label);
|
stageLabels_.push_back(label);
|
||||||
label->set_xalign(0.0F);
|
label->set_xalign(0.0F);
|
||||||
label->set_hexpand(true);
|
label->set_hexpand(true);
|
||||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||||
if (!stages_[i].enabled) label->add_css_class("dim-label");
|
if (skipped) label->add_css_class("dim-label");
|
||||||
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
|
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"
|
: index == firstRunning ? "Applied to the source images"
|
||||||
: "Applied to the output so far");
|
: "Applied to the output so far");
|
||||||
box->append(*label);
|
box->append(*label);
|
||||||
@@ -2053,13 +2120,18 @@ void EffectorWindow::onRecreate() {
|
|||||||
// ever handed the sources, so an effect that does not want them (blur) renders
|
// ever handed the sources, so an effect that does not want them (blur) renders
|
||||||
// happily with none loaded.
|
// happily with none loaded.
|
||||||
bool EffectorWindow::canRender() const {
|
bool EffectorWindow::canRender() const {
|
||||||
if (enabledStages(stages_).empty()) return false;
|
if (enabledStages(stages_, isVideo()).empty()) return false;
|
||||||
return !sources_->empty() || !chainNeedsSources(stages_);
|
return !sources_->empty() || !chainNeedsSources(stages_, isVideo());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string EffectorWindow::missingText() const {
|
std::string EffectorWindow::missingText() const {
|
||||||
if (stages_.empty()) return "Add an effect to start.";
|
if (stages_.empty()) return "Add an effect to start.";
|
||||||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
if (enabledStages(stages_, 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.";
|
return "Add source images under Setup to start.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2203,7 +2275,7 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
std::ostringstream status;
|
std::ostringstream status;
|
||||||
status.setf(std::ios::fixed);
|
status.setf(std::ios::fixed);
|
||||||
status.precision(1);
|
status.precision(1);
|
||||||
const std::size_t effects = enabledStages(stages_).size();
|
const std::size_t effects = enabledStages(stages_, wasVideo).size();
|
||||||
if (wasVideo) {
|
if (wasVideo) {
|
||||||
result_ = Image();
|
result_ = Image();
|
||||||
clip_ = std::move(incomingClip_);
|
clip_ = std::move(incomingClip_);
|
||||||
@@ -2226,7 +2298,7 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
|
|
||||||
// The line itself stays short; the full chain and setup go in the tooltip.
|
// The line itself stays short; the full chain and setup go in the tooltip.
|
||||||
std::ostringstream details;
|
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()) << " · ";
|
<< static_cast<int>(heightSpin_.get_value()) << " · ";
|
||||||
if (wasVideo) {
|
if (wasVideo) {
|
||||||
const auto video = videoSettings();
|
const auto video = videoSettings();
|
||||||
@@ -2610,7 +2682,7 @@ void EffectorWindow::onSaveProjectAs() {
|
|||||||
dialog->set_title("Save project");
|
dialog->set_title("Save project");
|
||||||
dialog->set_filters(projectFilters());
|
dialog->set_filters(projectFilters());
|
||||||
dialog->set_initial_name(projectPath_.empty()
|
dialog->set_initial_name(projectPath_.empty()
|
||||||
? "effector-" + chainSlug(stages_) + ".json"
|
? "effector-" + chainSlug(stages_, isVideo()) + ".json"
|
||||||
: std::filesystem::path(projectPath_).filename().string());
|
: std::filesystem::path(projectPath_).filename().string());
|
||||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
onSaveProjectChosen(result, dialog);
|
onSaveProjectChosen(result, dialog);
|
||||||
@@ -2662,7 +2734,7 @@ void EffectorWindow::onSave() {
|
|||||||
|
|
||||||
auto dialog = Gtk::FileDialog::create();
|
auto dialog = Gtk::FileDialog::create();
|
||||||
dialog->set_title(video ? "Save video" : "Save image");
|
dialog->set_title(video ? "Save video" : "Save image");
|
||||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
|
dialog->set_initial_name("effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) +
|
||||||
(video ? ".mp4" : ".png"));
|
(video ? ".mp4" : ".png"));
|
||||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
onSaveChosen(result, dialog);
|
onSaveChosen(result, dialog);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ private:
|
|||||||
|
|
||||||
Gtk::Widget& buildChainSection();
|
Gtk::Widget& buildChainSection();
|
||||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
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 appendStage(const EffectPtr& effect);
|
||||||
void removeStage(int index);
|
void removeStage(int index);
|
||||||
void moveStage(int from, int to); // `to` is the index it ends up at
|
void moveStage(int from, int to); // `to` is the index it ends up at
|
||||||
@@ -307,6 +308,10 @@ private:
|
|||||||
Gtk::Entry seedEntry_;
|
Gtk::Entry seedEntry_;
|
||||||
Gtk::ListBox chainList_;
|
Gtk::ListBox chainList_;
|
||||||
Gtk::MenuButton addEffectButton_;
|
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::MenuButton menuButton_;
|
||||||
Gtk::Button recreateButton_{"Recreate"};
|
Gtk::Button recreateButton_{"Recreate"};
|
||||||
Gtk::Button saveButton_{"Save As…"};
|
Gtk::Button saveButton_{"Save As…"};
|
||||||
|
|||||||
+17
-5
@@ -42,8 +42,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
|
|||||||
|
|
||||||
std::cout << options.output << " " << options.width << "x" << options.height << " "
|
std::cout << options.output << " " << options.width << "x" << options.height << " "
|
||||||
<< count << " frames at " << options.video.fps << " fps effect="
|
<< count << " frames at " << options.video.fps << " fps effect="
|
||||||
<< effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed
|
<< effector::chainSlug(stages, true) << " seed=" << seed << " render="
|
||||||
<< std::setprecision(1) << seconds << " s\n";
|
<< std::fixed << std::setprecision(1) << seconds << " s\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +71,17 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
stages.push_back({effect, effect->defaults()});
|
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;
|
std::vector<effector::LoadFailure> failures;
|
||||||
|
|
||||||
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
||||||
@@ -91,7 +102,7 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
for (const auto& failure : failures) {
|
for (const auto& failure : failures) {
|
||||||
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
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";
|
std::cerr << "effector: no usable source images\n";
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
image.save(options.output);
|
image.save(options.output);
|
||||||
|
|
||||||
std::cout << options.output << " " << options.width << "x" << options.height
|
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";
|
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -129,7 +140,8 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
if (options.listEffects) {
|
if (options.listEffects) {
|
||||||
for (const auto& effect : effector::effects()) {
|
for (const auto& effect : effector::effects()) {
|
||||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
std::cout << effect->name() << "\t" << effect->title()
|
||||||
|
<< (effect->video() ? " (video)" : "") << " - "
|
||||||
<< effect->description() << "\n";
|
<< effect->description() << "\n";
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
+82
-37
@@ -35,6 +35,7 @@
|
|||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
#include "effects/text.hpp"
|
#include "effects/text.hpp"
|
||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -1068,7 +1069,8 @@ int main() {
|
|||||||
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
||||||
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
||||||
name + " cascades reproducibly");
|
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.
|
// A canvas one pixel wide or high must not trip any of them up.
|
||||||
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
||||||
@@ -1195,8 +1197,8 @@ int main() {
|
|||||||
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||||
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||||
|
|
||||||
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||||
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
|
||||||
|
|
||||||
bool threw = false;
|
bool threw = false;
|
||||||
try {
|
try {
|
||||||
@@ -1211,10 +1213,10 @@ int main() {
|
|||||||
// them, blur only softens what it is handed and runs without any.
|
// them, blur only softens what it is handed and runs without any.
|
||||||
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||||
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||||
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
|
||||||
check(!chainNeedsSources({blur, scanlines}),
|
check(!chainNeedsSources({blur, scanlines}, false),
|
||||||
"a chain led by blur needs none, later stages see the stage before them");
|
"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);
|
Rng bare(5);
|
||||||
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||||
@@ -1241,12 +1243,12 @@ int main() {
|
|||||||
Rng skipping(5);
|
Rng skipping(5);
|
||||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
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(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(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
|
||||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
|
||||||
|
|
||||||
// Whoever leads the chain is handed the sources, and that is the first
|
// Whoever leads the chain is handed the sources, and that is the first
|
||||||
// stage still switched on.
|
// 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");
|
"a disabled leading stage leaves the question to the stage behind it");
|
||||||
Rng leading(5);
|
Rng leading(5);
|
||||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||||
@@ -1821,44 +1823,87 @@ int main() {
|
|||||||
"a video is recognised by its extension");
|
"a video is recognised by its extension");
|
||||||
|
|
||||||
const auto split = effector::effectByName("channelsplit");
|
const auto split = effector::effectByName("channelsplit");
|
||||||
|
const auto tracking = effector::effectByName("tracking");
|
||||||
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||||
|
|
||||||
// A still ignores every motion param: the picture is the one it
|
check(tracking->video() && !split->video(),
|
||||||
// always was, whatever they are set to.
|
"VHS tracking is a video effect, channel split an image one");
|
||||||
auto calm = split->defaults();
|
bool imagesFirst = true;
|
||||||
calm["split_mode"] = std::string("per_band");
|
bool seenVideo = false;
|
||||||
auto wild = calm;
|
for (const auto& effect : effects()) {
|
||||||
wild["split_wobble"] = 40L;
|
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
|
||||||
wild["split_jitter"] = 30L;
|
seenVideo = seenVideo || effect->video();
|
||||||
wild["split_roll"] = 60L;
|
}
|
||||||
effector::Rng stillA(5);
|
check(imagesFirst, "the registry lists the image effects before the video effects");
|
||||||
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(split->animated(split->defaults()), "channel split moves by default in a video");
|
auto wild = tracking->defaults();
|
||||||
auto frozen = split->defaults();
|
wild["tracking_wobble"] = 40L;
|
||||||
frozen["split_wobble"] = 0L;
|
wild["tracking_jitter"] = 30L;
|
||||||
frozen["split_jitter"] = 0L;
|
wild["tracking_bar"] = 60L;
|
||||||
frozen["split_roll"] = 0L;
|
auto frozen = tracking->defaults();
|
||||||
check(!split->animated(frozen), "and holds still with every motion at 0");
|
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.
|
// frame, so what comes after it holds still.
|
||||||
effector::Rng frameA(9);
|
effector::Rng frameA(9);
|
||||||
effector::Rng frameB(9);
|
effector::Rng frameB(9);
|
||||||
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
tracking->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, frameB, wild, FrameTime{17, 30, 30});
|
||||||
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
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 = {}) {
|
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
|
||||||
ChainStage built;
|
ChainStage built;
|
||||||
built.effect = effector::effectByName(name);
|
built.effect = effector::effectByName(name);
|
||||||
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
||||||
return built;
|
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;
|
VideoSettings video;
|
||||||
video.enabled = true;
|
video.enabled = true;
|
||||||
video.duration = 1.0;
|
video.duration = 1.0;
|
||||||
@@ -1896,7 +1941,7 @@ int main() {
|
|||||||
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
||||||
"frames of a moving chain differ");
|
"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;
|
std::vector<effector::Image> held;
|
||||||
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
||||||
[&](int, effector::Image&& frame) {
|
[&](int, effector::Image&& frame) {
|
||||||
@@ -1907,7 +1952,7 @@ int main() {
|
|||||||
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
||||||
effector::Rng stillRng(21);
|
effector::Rng stillRng(21);
|
||||||
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
|
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;
|
int seen = 0;
|
||||||
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
@@ -1931,7 +1976,7 @@ int main() {
|
|||||||
|
|
||||||
// The project file: a video says so, a still says nothing about time.
|
// The project file: a video says so, a still says nothing about time.
|
||||||
effector::Project project;
|
effector::Project project;
|
||||||
project.stages = {stage("channelsplit", wild)};
|
project.stages = {stage("tracking", wild)};
|
||||||
const auto stillJson = effector::projectToJson(project);
|
const auto stillJson = effector::projectToJson(project);
|
||||||
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
|
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");
|
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
|
||||||
@@ -1944,8 +1989,8 @@ int main() {
|
|||||||
back.video.preset == "instagram-story",
|
back.video.preset == "instagram-story",
|
||||||
"a video project keeps its length, rate and preset");
|
"a video project keeps its length, rate and preset");
|
||||||
const auto motion = back.stages.at(0).values;
|
const auto motion = back.stages.at(0).values;
|
||||||
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
|
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
|
||||||
effector::paramInt(motion, "split_roll", 0) == 60,
|
effector::paramInt(motion, "tracking_bar", 0) == 60,
|
||||||
"and its stages keep their motion");
|
"and its stages keep their motion");
|
||||||
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
||||||
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
||||||
|
|||||||
+82
-14
@@ -109,6 +109,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
|
|||||||
return nullptr;
|
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
|
// 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.
|
// quarter of a second before the button reports the click, so wait it out.
|
||||||
void press(Gtk::Button& button) {
|
void press(Gtk::Button& button) {
|
||||||
@@ -153,6 +161,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
|
|||||||
|
|
||||||
void runTests(Gtk::Window& window) {
|
void runTests(Gtk::Window& window) {
|
||||||
const auto& effects = effector::effects();
|
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";
|
std::cout << "sidebar\n";
|
||||||
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
|
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
|
||||||
@@ -195,6 +208,19 @@ void runTests(Gtk::Window& window) {
|
|||||||
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
|
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
|
||||||
check(listed, "the menu has an entry for every registered effect");
|
check(listed, "the menu has an entry for every registered effect");
|
||||||
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
|
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();
|
add->popup();
|
||||||
pump();
|
pump();
|
||||||
@@ -204,12 +230,19 @@ void runTests(Gtk::Window& window) {
|
|||||||
// there were enough effects: the entries have to scroll instead, in a
|
// 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
|
// menu short enough to open below a button near the top of a 768-pixel
|
||||||
// screen.
|
// 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 auto scrollers = all<Gtk::ScrolledWindow>(*menu);
|
||||||
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
|
int cap = 0;
|
||||||
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
|
for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
|
||||||
"the entries scroll");
|
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(cap > 0 && cap <= 480, "past a height that fits a small screen");
|
||||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
|
check(menu->get_height() > 0 && menu->get_height() <= cap + 120,
|
||||||
"and the menu stays within it however many effects there are");
|
"and the menu stays within it however many effects there are");
|
||||||
add->popdown();
|
add->popdown();
|
||||||
pump();
|
pump();
|
||||||
@@ -306,15 +339,19 @@ void runTests(Gtk::Window& window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "many stages\n";
|
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();
|
pump();
|
||||||
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
||||||
check(split != nullptr && fewSplit > emptySplit,
|
check(split != nullptr && fewSplit > emptySplit,
|
||||||
"the split moves down as the chain grows");
|
"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(rowCount(*chainList) == 13, "the menu still works with a long chain");
|
||||||
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
|
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
|
||||||
"including for the last effect in the menu");
|
"including for the last image effect in the menu");
|
||||||
pump();
|
pump();
|
||||||
const int longSplit = split != nullptr ? split->get_position() : -1;
|
const int longSplit = split != nullptr ? split->get_position() : -1;
|
||||||
check(longSplit > fewSplit && listScroller != nullptr &&
|
check(longSplit > fewSplit && listScroller != nullptr &&
|
||||||
@@ -455,7 +492,9 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
|||||||
std::filesystem::create_directories(dir);
|
std::filesystem::create_directories(dir);
|
||||||
const auto path = dir / "moving.json";
|
const auto path = dir / "moving.json";
|
||||||
effector::Project project;
|
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);
|
effector::saveProject(path.string(), project);
|
||||||
window.activate_action("win.open-recent",
|
window.activate_action("win.open-recent",
|
||||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
@@ -468,22 +507,49 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
|||||||
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
|
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
|
||||||
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
|
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
|
||||||
auto* playback = named<Gtk::Box>(window, "playback");
|
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 &&
|
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
|
||||||
height != nullptr && playback != nullptr,
|
height != nullptr && playback != nullptr,
|
||||||
"the video switch, its settings and the playback bar are there");
|
"the video switch, its settings and the playback bar are there");
|
||||||
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
|
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
|
||||||
height == nullptr || playback == nullptr) {
|
height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
|
||||||
return;
|
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(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
|
||||||
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
|
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
|
||||||
"a still project shows no video settings and no playback");
|
"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);
|
toggle->set_active(true);
|
||||||
pump(200);
|
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"),
|
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");
|
stack->set_visible_child("setup");
|
||||||
pump(100);
|
pump(100);
|
||||||
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
||||||
@@ -511,8 +577,10 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
|||||||
|
|
||||||
toggle->set_active(false);
|
toggle->set_active(false);
|
||||||
pump(300);
|
pump(300);
|
||||||
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
|
videoLabel = rowLabel(1);
|
||||||
"switched back to a still, the playback and motion params go");
|
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");
|
stack->set_visible_child("chain");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user