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:
Si
2026-09-26 19:03:39 +02:00
co-authored by Claude Opus 5.5
parent ea59db5acd
commit cdaadffc89
23 changed files with 752 additions and 376 deletions
+20
View File
@@ -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
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
### Fixed
+33 -15
View File
@@ -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
all, and both front-ends gate on that rather than on "are there any images".
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect` (or
`VideoEffect` for one that moves, see "Video projects"), one
line in the registry list in `src/core/registry.cpp`, one line in the
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
CLI, in the chain menu and as a cascade stage on its own.
@@ -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
duration and fps. Adding a preset is a line there.
Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default,
`fps == 0`, is a still) through `Effect::renderFrame`, which falls back to
`render` — an effect that does not move overrides nothing. One that moves
overrides `renderFrame` and `animated(values)`, and marks its motion params
`.forVideo()`: the interface hides those in a still project (declaratively,
like `.styled()`), the CLI still has their flags. A still must render byte for
byte what it did before the effect learned to move.
Effects are of two kinds, and `Effect::video()` says which. An *image effect*
(the default) makes a picture and holds still in a clip; it overrides only
`render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
and nothing else. `enabledStages(stages, video)` is where the kinds meet the
chain: in a still, a video stage is left out exactly like a disabled one,
drawing nothing from the rng. So `renderChain` (by `frame.video()`),
`chainNeedsSources`, `chainTitle` and `chainSlug` all take whether the render is
a video. The interface builds the split from `video()` alone: two sections in
the Add effect menu, with the video section insensitive in a still, a
`camera-video-symbolic` icon on a video stage's row, and the row dimmed while a
still skips it. The CLI groups `--help` the same way and warns when it skips one.
A video effect subclasses `VideoEffect` (`src/effects/video_effect.hpp`), not
`Effect`, and 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
`Rng(seed)`, so a stage that does not move draws the same values every frame
and holds still. A moving effect must therefore leave the shared rng in the
same state on every frame — the channel split draws its per-frame shake from a
generator of its own, seeded by one draw — or everything after it would flicker.
The stages before the first `animated()` one are rendered once and their image
and rng state reused; the `video` tests assert that frame k is exactly
`renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output.
`Rng(seed)`, so an image effect draws the same values every frame and holds
still, and because `VideoEffect` leaves the shared rng in one state, the stages
after a video effect hold still too. The stages before the first video effect
are rendered once and their image and rng state reused. The `video` tests
assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
shortcut can never change output.
The determinism contract covers the frames; the encoded file is whatever the
machine's ffmpeg makes of them.
+3 -1
View File
@@ -1,5 +1,5 @@
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_REQUIRED ON)
@@ -56,6 +56,8 @@ add_library(effector_core STATIC
src/effects/slitshift.cpp
src/effects/text.cpp
src/effects/tiles.cpp
src/effects/tracking.cpp
src/effects/video_effect.cpp
src/effects/warp.cpp
)
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
+14 -9
View File
@@ -73,18 +73,23 @@ installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
palette GIF.
```bash
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 photo.jpg
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e channelsplit photo.jpg
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -e tracking --seed 1 photo.jpg
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e tracking --tracking-bar 40 photo.jpg
```
`--duration SECONDS` and `--fps N` make any run a video; a preset fills in
whatever was not given alongside it. Effects that move over time are video
effects; for now that is **Channel split**, which in a video sways its
channels, shakes them from frame to frame and rolls a tracking bar down the
picture, like a worn tape on an old tube. Its motion params only show in a
video project, and a still renders exactly as before. Every frame starts from
the same seed, so stages that do not move hold still while one that does
moves, and a clip renders frame for frame the same again.
whatever was not given alongside it.
Effects come in two kinds. **Image effects** make a picture and hold still in a
clip. **Video effects** are motion: the Add effect menu lists them under a
heading of their own, and they can only be added once the project is a video.
In a still project a video stage is marked as skipped and the render leaves it
out, as it would a stage that is switched off. For now there is one video
effect, **VHS tracking**: the colour planes sway and shake, and a tracking bar
rolls down the picture, like a worn tape on an old tube. Put it after the
image effects it should shake. Every frame starts from the same seed, so the
image effects hold still while a video effect moves them, and a clip renders
frame for frame the same again.
Any option can also be passed when launching the interface; it becomes the
starting state (images, canvas size, effect settings, seed).
+1 -1
View File
@@ -2,7 +2,7 @@
# so pkgver must follow the project() line of CMakeLists.txt (the version core
# test checks it). From this folder: makepkg -si
pkgname=effector
pkgver=0.6.1
pkgver=0.7.0
pkgrel=1
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
arch=('x86_64' 'aarch64')
+16 -9
View File
@@ -264,15 +264,22 @@ std::string usage(const char* programName) {
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
}
for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help)
<< (spec.videoOnly ? " (video only)" : "") << "\n";
// Image effects first, then the video effects under a heading of their
// own: those run only in a clip.
for (const bool video : {false, true}) {
if (video) {
out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
}
for (const auto& effect : effects()) {
if (effect->video() != video) continue;
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
}
}
}
return out.str();
+29 -29
View File
@@ -6,11 +6,13 @@
namespace effector {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video) {
std::vector<const ChainStage*> running;
running.reserve(stages.size());
for (const auto& stage : stages) {
if (stage.enabled && stage.effect) running.push_back(&stage);
if (stage.enabled && stage.effect && (video || !stage.effect->video())) {
running.push_back(&stage);
}
}
return running;
}
@@ -36,7 +38,7 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
namespace {
// The stages that run, checked: throws for a chain with nothing to render.
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
@@ -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
// image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added.
auto running = enabledStages(stages);
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
// back exactly the picture the chain made before it was added. A video
// effect in a still is left out the same way.
auto running = enabledStages(stages, video);
if (running.empty()) {
const bool videoLeft = std::any_of(stages.begin(), stages.end(), [](const ChainStage& stage) {
return stage.enabled && stage.effect->video();
});
throw EffectError(videoLeft ? "Video effects only run in a video, and the chain has "
"no image effect switched on."
: "Every effect in the chain is disabled.");
}
return running;
}
@@ -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,
int width, int height, Rng& rng, const StyleSet& styles,
const FrameTime& frame) {
const auto running = runningStages(stages);
const auto running = runningStages(stages, frame.video());
ensureRenderFits(running, width, height, styles);
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
frame);
}
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles) {
for (const auto* stage : enabledStages(stages)) {
if (stage->effect->animated(stageValues(*stage, styles))) return true;
}
return false;
}
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, std::uint64_t seed, const StyleSet& styles,
const VideoSettings& video,
const std::function<bool(int index, Image&& frame)>& onFrame) {
const auto running = runningStages(stages);
const auto running = runningStages(stages, true);
ensureRenderFits(running, width, height, styles);
const int count = video.frameCount();
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
// Everything before the first stage that moves is the same on every
// frame -- the same seed, the same values, no time -- so it is rendered
// once, as frame 0, and picked up from there. The rng is copied along
// with the picture, which is what makes the shortcut exact.
// Everything before the first video effect is the same on every frame --
// the same seed, the same values, no time -- so it is rendered once, as
// frame 0, and picked up from there. The rng is copied along with the
// picture, which is what makes the shortcut exact.
std::size_t still = 0;
while (still < running.size() &&
!running[still]->effect->animated(stageValues(*running[still], styles))) {
++still;
}
while (still < running.size() && !running[still]->effect->video()) ++still;
Rng prefixRng(seed);
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
styles, FrameTime{0, count, fps});
@@ -146,24 +146,24 @@ int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>
return count;
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
const auto running = enabledStages(stages);
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
const auto running = enabledStages(stages, video);
if (running.empty()) return false;
return running.front()->effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string chainTitle(const std::vector<ChainStage>& stages, bool video) {
std::string text;
for (const auto* stage : enabledStages(stages)) {
for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += " → ";
text += stage->effect->title();
}
return text;
}
std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
std::string text;
for (const auto* stage : enabledStages(stages)) {
for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += "-";
text += stage->effect->name();
}
+14 -15
View File
@@ -40,11 +40,12 @@ struct ChainStage {
// that style carries. The one place a style is resolved.
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
// The stages that actually run, in order, as pointers into `stages`. A
// disabled stage draws nothing from the rng and is handed no image, so a chain
// renders exactly as it would if that stage had never been added -- this is
// the one place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
// The stages that actually run, in order, as pointers into `stages`: those
// switched on, and of those the video effects only in a video. A stage left
// out draws nothing from the rng and is handed no image, so a chain renders
// exactly as it would if that stage had never been added -- this is the one
// place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video);
// Renders the enabled stages in order. The first one sees the loaded sources;
// every later one sees the previous stage's output as its only source, so blur
@@ -55,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
// line's -- passes none and renders from the stages alone.
//
// `frame` says where in a video the render sits, a still by default; every
// stage is handed it, and only one that moves makes anything of it.
// `frame` says where in a video the render sits, a still by default, in
// which the video effects are skipped; every stage that runs is handed it,
// and only a video effect makes anything of it.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng, const StyleSet& styles = {},
const FrameTime& frame = {});
// Whether any stage that runs moves over time with the values it runs with.
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles = {});
// Renders every frame of a video, in order, handing each to `onFrame`; it
// returning false stops the render there. Frame k is exactly what
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} --
// every frame starts from the same seed, so a stage that does not move
// renders the same picture throughout rather than reshuffling on every
// frame. The stages before the first one that moves are rendered once, and
// frame. The stages before the first video effect are rendered once, and
// their picture and the rng state they leave are reused for every frame.
// Returns the number of frames handed out.
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
// runs ever sees them — every later one works on the stage before it — so this
// is simply whether that effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages);
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video);
// "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages);
// what was rendered, so a stage that did not run is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
// "scanlines-blur", for file names.
std::string chainSlug(const std::vector<ChainStage>& stages);
std::string chainSlug(const std::vector<ChainStage>& stages, bool video);
} // namespace effector
+12 -11
View File
@@ -58,21 +58,22 @@ public:
return title();
}
// Whether a stage with these values changes from one frame of a video to
// the next. A stage that does not is rendered once for the whole clip.
virtual bool animated(const ParamValues& values) const {
(void)values;
return false;
}
// Whether this is a video effect rather than an image effect. An image
// effect makes a picture and holds still in a clip; a video effect is
// motion and nothing else, so it is offered only in a video project and a
// still skips it whole, as it skips a stage that is switched off. Video
// effects derive from VideoEffect (effects/video_effect.hpp), which
// answers true here and keeps the rules a moving stage has to follow.
virtual bool video() const { return false; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
// One frame of a video, or a still when `frame` says so. An effect that
// does not move needs only render(), which this falls back to. One that
// does must leave `rng` in the same state on every frame -- a frame's own
// randomness comes from a generator of its own -- so the stages after it
// stay put while it moves.
// One frame of a video, or a still when `frame` says so. An image effect
// needs only render(), which this falls back to; a video effect renders
// here, and must leave `rng` in the same state on every frame -- a
// frame's own randomness comes from a generator of its own -- so the
// stages after it stay put while it moves.
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const {
(void)frame;
-6
View File
@@ -16,12 +16,6 @@ ParamSpec ParamSpec::styled() const {
return copy;
}
ParamSpec ParamSpec::forVideo() const {
ParamSpec copy = *this;
copy.videoOnly = true;
return copy;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
-7
View File
@@ -27,10 +27,6 @@ struct ParamSpec {
// Whether a named style carries this param rather than the stage it sits
// on: see core/style.hpp.
bool inStyle = false;
// Whether the param only means something in a video project -- how a
// stage moves over time. The interface leaves it out of a still project,
// where it would change nothing.
bool videoOnly = false;
std::string flag() const; // "strip_height" -> "--strip-height"
@@ -38,9 +34,6 @@ struct ParamSpec {
// reads makeDouble("font_size", ...).styled() and the factories keep the
// signatures they have.
ParamSpec styled() const;
// A copy of this spec marked as mattering only in a video project, read
// the same way: makeInt("split_jitter", ...).forVideo().
ParamSpec forVideo() const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1);
+3
View File
@@ -14,6 +14,7 @@
#include "effects/slitshift.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
#include "effects/tracking.hpp"
#include "effects/warp.hpp"
namespace effector {
@@ -36,6 +37,8 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<WarpEffect>(),
std::make_shared<BitCrushEffect>(),
std::make_shared<FeedbackEffect>(),
// Video effects, offered only in a video project.
std::make_shared<TrackingEffect>(),
};
return registry;
}
+8 -172
View File
@@ -2,8 +2,6 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <numbers>
#include <vector>
#include "core/parallel.hpp"
@@ -24,26 +22,6 @@ const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
"rgb moves all three channels apart; red_cyan moves red one way and "
"green and blue the other, as 3D glasses would; random moves one "
"channel picked at random."),
ParamSpec::makeInt("split_wobble", "Wobble", 4, 0, 256,
"How far in pixels the channels sway back and forth over time.")
.forVideo(),
ParamSpec::makeDouble("split_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
"Sways per second, rounded so that the clip loops without a jump.",
0.1, 1)
.forVideo(),
ParamSpec::makeInt("split_jitter", "Jitter", 2, 0, 256,
"Furthest in pixels the channels shake, anew on every frame and, in "
"per_band mode, for every band.")
.forVideo(),
ParamSpec::makeInt("split_roll", "Tracking bar", 16, 0, 512,
"How far in pixels a bar rolling down the picture tears the rows "
"sideways and the channels apart; 0 for none.")
.forVideo(),
ParamSpec::makeDouble("split_roll_time", "Bar pass", 4.0, 0.5, 60.0,
"Seconds the tracking bar takes from top to bottom, rounded so "
"that the clip loops without a jump.",
0.5, 1)
.forVideo(),
};
return specs;
}
@@ -57,68 +35,28 @@ struct Offset {
using ChannelOffsets = std::array<Offset, 3>;
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) {
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
ChannelOffsets offsets{};
auto draw = [&] {
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
static_cast<int>(rng.inRange(-amount, amount))};
};
moved = -1;
if (channels == "red_cyan") {
const Offset red = draw();
offsets[0] = red;
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
} else if (channels == "random") {
moved = static_cast<int>(rng.below(2));
offsets[static_cast<std::size_t>(moved)] = draw();
offsets[rng.below(2)] = draw();
} else {
for (auto& offset : offsets) offset = draw();
}
return offsets;
}
// A motion of each channel, handed out the way the mode moves channels: all
// three on their own, green and blue opposite red, or only the one channel
// that was picked -- so the motion never moves a channel the still left alone.
using Motion = std::array<double, 3>;
} // namespace
Motion spread(const Motion& motion, const std::string& channels, int moved) {
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]};
if (moved >= 0) {
Motion only{};
only[static_cast<std::size_t>(moved)] = motion[0];
return only;
}
return motion;
}
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
struct MotionSettings {
int wobble = 0;
double wobbleSpeed = 0.0;
int jitter = 0;
int roll = 0;
double rollTime = 1.0;
explicit MotionSettings(const ParamValues& values)
: wobble(static_cast<int>(std::max(0L, paramInt(values, "split_wobble", 4)))),
wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "split_jitter", 2)))),
roll(static_cast<int>(std::max(0L, paramInt(values, "split_roll", 16)))),
rollTime(std::max(0.5, paramDouble(values, "split_roll_time", 4.0))) {}
bool moves() const { return (wobble > 0 && wobbleSpeed > 0.0) || jitter > 0 || roll > 0; }
};
// Rounds a rate so a whole number of cycles fits the clip, never fewer than
// one: the last frame then leads straight back into the first.
double loopRate(double perSecond, double clipSeconds) {
return std::max(1.0, std::round(perSecond * clipSeconds)) / clipSeconds;
}
Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) {
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
@@ -133,105 +71,20 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
const int band = perBand ? bandHeight : height;
const int bandCount = (height + band - 1) / band;
std::vector<ChannelOffsets> plan;
std::vector<int> moved;
plan.reserve(static_cast<std::size_t>(bandCount));
moved.reserve(static_cast<std::size_t>(bandCount));
for (int i = 0; i < bandCount; ++i) {
int only = -1;
plan.push_back(drawOffsets(rng, amount, channels, only));
moved.push_back(only);
}
// Every row's offsets, and a sideways shift of the whole row. A still is
// just the band plan; a frame of a video adds the motion to it.
std::vector<ChannelOffsets> rows(static_cast<std::size_t>(height));
std::vector<int> shift(static_cast<std::size_t>(height), 0);
for (int y = 0; y < height; ++y) rows[static_cast<std::size_t>(y)] = plan[static_cast<std::size_t>(y / band)];
const MotionSettings motion(values);
if (frame.video() && motion.moves()) {
// The same draws on every frame, after the still's: where each
// channel starts its sway and which way it sways. The shake is the
// one thing that differs between frames, so it comes from a generator
// of the frame's own, seeded by one more draw -- the shared rng leaves
// every frame in the same state, and the stages after this one hold
// still while it moves.
std::array<double, 3> phase{};
std::array<double, 3> angle{};
for (int c = 0; c < 3; ++c) {
phase[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
angle[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
}
Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame.index + 1)));
const double clip = static_cast<double>(std::max(1, frame.count)) / frame.fps;
const double t = frame.seconds();
Motion swayX{};
Motion swayY{};
if (motion.wobble > 0 && motion.wobbleSpeed > 0.0) {
const double rate = loopRate(motion.wobbleSpeed, clip);
for (std::size_t c = 0; c < 3; ++c) {
const double sway = motion.wobble * std::sin(2.0 * std::numbers::pi * rate * t + phase[c]);
swayX[c] = sway * std::cos(angle[c]);
swayY[c] = sway * std::sin(angle[c]);
}
}
// The tracking bar: an eighth of the picture, soft at both edges,
// entering above the top and leaving below the bottom.
const double barHeight = std::max(8.0, height / 8.0);
const double passes = loopRate(1.0 / motion.rollTime, clip);
const double along = std::fmod(t * passes, 1.0);
const double barTop = -barHeight + along * (height + barHeight);
for (int b = 0; b < bandCount; ++b) {
Motion jitterX{};
Motion jitterY{};
if (motion.jitter > 0) {
for (std::size_t c = 0; c < 3; ++c) {
jitterX[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter));
// Mostly sideways, as a picture losing its horizontal hold.
jitterY[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter)) / 3.0;
}
}
const int only = moved[static_cast<std::size_t>(b)];
const Motion dx = spread({swayX[0] + jitterX[0], swayX[1] + jitterX[1], swayX[2] + jitterX[2]},
channels, only);
const Motion dy = spread({swayY[0] + jitterY[0], swayY[1] + jitterY[1], swayY[2] + jitterY[2]},
channels, only);
// The bar pulls red one way and blue the other (or the moved
// channel alone), whatever the sway is doing.
const Motion apart = spread({1.0, 0.0, -1.0}, channels, only);
const int end = std::min(height, (b + 1) * band);
for (int y = b * band; y < end; ++y) {
double bar = 0.0;
if (motion.roll > 0 && y >= barTop && y < barTop + barHeight) {
bar = motion.roll * std::sin(std::numbers::pi * (y - barTop) / barHeight);
}
auto& offsets = rows[static_cast<std::size_t>(y)];
for (std::size_t c = 0; c < 3; ++c) {
offsets[c].dx += static_cast<int>(std::lround(dx[c] + bar * apart[c]));
offsets[c].dy += static_cast<int>(std::lround(dy[c]));
}
shift[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(bar / 2.0));
}
}
}
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const ChannelOffsets& offsets = rows[y];
const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int c = 0; c < 3; ++c) {
const Offset& offset = offsets[static_cast<std::size_t>(c)];
const int dx = offset.dx + shift[y];
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
const std::uint8_t* in = input.row(sy);
for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - dx, 0, width - 1);
const int sx = std::clamp(x - offset.dx, 0, width - 1);
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
}
}
@@ -241,21 +94,4 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
return canvas;
}
} // namespace
bool ChannelSplitEffect::animated(const ParamValues& values) const {
return MotionSettings(values).moves();
}
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
return renderSplit(sources, width, height, rng, values, FrameTime{});
}
Image ChannelSplitEffect::renderFrame(const std::vector<Image>& sources, int width, int height,
Rng& rng, const ParamValues& values,
const FrameTime& frame) const {
return renderSplit(sources, width, height, rng, values, frame);
}
} // namespace effector
-6
View File
@@ -6,9 +6,6 @@ namespace effector {
// Knocks the red, green and blue planes out of register, either the whole image
// at once or band by band, each band with offsets of its own.
// In a video the channels do not sit still: they sway, shake from frame to
// frame, and a tracking bar rolls down the picture as on a worn tape or an old
// tube. A still picture is rendered exactly as it always was.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class ChannelSplitEffect : public Effect {
@@ -21,12 +18,9 @@ public:
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
bool animated(const ParamValues& values) const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const override;
};
} // namespace effector
+147
View File
@@ -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
+29
View File
@@ -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
+47
View File
@@ -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
+79
View File
@@ -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
+111 -39
View File
@@ -381,7 +381,7 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesSection_.set_visible(chainNeedsSources(stages_));
sourcesSection_.set_visible(chainNeedsSources(stages_, isVideo()));
}
// The heading carries the count, which a long list scrolled away would hide.
@@ -608,7 +608,9 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
suspendVideoSync_ = suspended;
playbackBar_.set_visible(false);
clearClip();
buildParamWidgets(); // the motion params come and go with the switch
updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets();
}
void EffectorWindow::onVideoToggled() {
@@ -619,6 +621,8 @@ void EffectorWindow::onVideoToggled() {
result_ = Image();
clearClip();
saveButton_.set_sensitive(false);
updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets();
requestRender();
}
@@ -805,8 +809,11 @@ void EffectorWindow::buildParamWidgets() {
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
const bool skipped = stage.effect->video() && !isVideo();
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
(stage.enabled ? " settings" : " settings (off)"));
(!stage.enabled ? " settings (off)"
: skipped ? " settings (video only)"
: " settings"));
int row = 0;
// The style the stage follows, when its effect takes one at all: the
@@ -829,9 +836,6 @@ void EffectorWindow::buildParamWidgets() {
}
for (const auto& spec : stage.effect->params()) {
// How a stage moves means nothing in a still, so it is left out there;
// the values stay with the stage for when the project becomes a video.
if (spec.videoOnly && !isVideo()) continue;
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
@@ -1707,39 +1711,86 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
return *box;
}
// The tallest the menu of effects grows before it scrolls, in pixels.
constexpr int addMenuMaxHeight = 480;
// The tallest each part of the menu of effects grows before it scrolls, in
// pixels: together, with their headings, short enough to open below a button
// near the top of a small screen.
constexpr int addMenuImageHeight = 330;
constexpr int addMenuVideoHeight = 110;
// One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it.
// file knowing anything about it. The image effects and the video effects
// are two sections, each under its own heading and each scrolling on its own,
// so the video effects stay in sight however many image effects there are;
// they can only be picked in a video project.
Gtk::Widget& EffectorWindow::buildAddMenu() {
auto* popover = Gtk::make_managed<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
auto* sections = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
list->append(*item);
for (const bool video : {false, true}) {
if (video) {
auto* separator = Gtk::make_managed<Gtk::Separator>();
separator->set_margin_top(6);
separator->set_margin_bottom(4);
sections->append(*separator);
}
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
heading->set_margin_start(8);
heading->set_margin_top(4);
heading->set_margin_bottom(2);
heading->append(*Gtk::make_managed<Gtk::Image>(Gio::ThemedIcon::create(
video ? "camera-video-symbolic" : "camera-photo-symbolic")));
auto* title = Gtk::make_managed<Gtk::Label>(video ? "Video effects" : "Image effects");
title->add_css_class("heading");
heading->append(*title);
sections->append(*heading);
if (video) {
videoEffectsHint_ = Gtk::make_managed<Gtk::Label>("Switch on Video under Setup to add these.");
videoEffectsHint_->add_css_class("dim-label");
videoEffectsHint_->set_xalign(0.0F);
videoEffectsHint_->set_margin_start(8);
videoEffectsHint_->set_wrap(true);
videoEffectsHint_->set_max_width_chars(28);
sections->append(*videoEffectsHint_);
}
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
if (effect->video() != video) continue;
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
if (video) videoEffectItems_.push_back(item);
list->append(*item);
}
// A popover that does not fit on the screen is not shown at all, and
// the lists grow with every effect registered, so past a point they
// scroll.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(video ? addMenuVideoHeight : addMenuImageHeight);
scroller->set_child(*list);
sections->append(*scroller);
}
updateVideoEffectItems();
// A popover that does not fit on the screen is not shown at all, and the
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
popover->set_child(*sections);
return *popover;
}
// A video effect has nothing to do in a still, so it is offered only once the
// project is a video.
void EffectorWindow::updateVideoEffectItems() {
for (auto* item : videoEffectItems_) item->set_sensitive(isVideo());
if (videoEffectsHint_ != nullptr) videoEffectsHint_->set_visible(!isVideo());
}
void EffectorWindow::appendStage(const EffectPtr& effect) {
stages_.push_back({effect, effect->defaults()});
selectedStage_ = static_cast<int>(stages_.size()) - 1;
@@ -1782,7 +1833,8 @@ void EffectorWindow::rebuildChainRows() {
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const bool video = isVideo();
const auto running = enabledStages(stages_, video);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
@@ -1809,13 +1861,28 @@ void EffectorWindow::rebuildChainRows() {
});
box->append(*check);
// A video effect is marked as one, and in a still it is dimmed like a
// stage that is switched off: the render skips it either way.
const bool videoEffect = stages_[i].effect->video();
const bool skipped = !stages_[i].enabled || (videoEffect && !video);
if (videoEffect) {
auto* icon = Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("camera-video-symbolic"));
icon->set_tooltip_text("A video effect");
icon->set_margin_start(4);
icon->set_margin_end(2);
if (skipped) icon->add_css_class("dim-label");
box->append(*icon);
}
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
stageLabels_.push_back(label);
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
if (!stages_[i].enabled) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
if (skipped) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: skipped ? "A video effect: skipped in a still picture"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
box->append(*label);
@@ -2053,13 +2120,18 @@ void EffectorWindow::onRecreate() {
// ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded.
bool EffectorWindow::canRender() const {
if (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
if (enabledStages(stages_, isVideo()).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_, isVideo());
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
if (enabledStages(stages_, isVideo()).empty()) {
if (!enabledStages(stages_, true).empty()) {
return "Video effects run only in a video: switch on Video under Setup.";
}
return "Every effect is switched off.";
}
return "Add source images under Setup to start.";
}
@@ -2203,7 +2275,7 @@ void EffectorWindow::onRenderFinished() {
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
const std::size_t effects = enabledStages(stages_).size();
const std::size_t effects = enabledStages(stages_, wasVideo).size();
if (wasVideo) {
result_ = Image();
clip_ = std::move(incomingClip_);
@@ -2226,7 +2298,7 @@ void EffectorWindow::onRenderFinished() {
// The line itself stays short; the full chain and setup go in the tooltip.
std::ostringstream details;
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
details << chainTitle(stages_, wasVideo) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
<< static_cast<int>(heightSpin_.get_value()) << " · ";
if (wasVideo) {
const auto video = videoSettings();
@@ -2610,7 +2682,7 @@ void EffectorWindow::onSaveProjectAs() {
dialog->set_title("Save project");
dialog->set_filters(projectFilters());
dialog->set_initial_name(projectPath_.empty()
? "effector-" + chainSlug(stages_) + ".json"
? "effector-" + chainSlug(stages_, isVideo()) + ".json"
: std::filesystem::path(projectPath_).filename().string());
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveProjectChosen(result, dialog);
@@ -2662,7 +2734,7 @@ void EffectorWindow::onSave() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title(video ? "Save video" : "Save image");
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
dialog->set_initial_name("effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) +
(video ? ".mp4" : ".png"));
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
+5
View File
@@ -118,6 +118,7 @@ private:
Gtk::Widget& buildChainSection();
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
void updateVideoEffectItems(); // offers the video effects in a video only
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int from, int to); // `to` is the index it ends up at
@@ -307,6 +308,10 @@ private:
Gtk::Entry seedEntry_;
Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_;
// The entries of the video effects in its menu, and the line saying why
// they cannot be picked in a still; both owned by the menu.
std::vector<Gtk::Button*> videoEffectItems_;
Gtk::Label* videoEffectsHint_ = nullptr;
Gtk::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
+17 -5
View File
@@ -42,8 +42,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
std::cout << options.output << " " << options.width << "x" << options.height << " "
<< count << " frames at " << options.video.fps << " fps effect="
<< effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed
<< std::setprecision(1) << seconds << " s\n";
<< effector::chainSlug(stages, true) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << seconds << " s\n";
return 0;
}
@@ -71,6 +71,17 @@ int renderHeadless(const effector::Options& options) {
stages.push_back({effect, effect->defaults()});
}
// A video effect has nothing to do in a still; the render skips it, and
// says so here rather than leave it looking forgotten.
if (!options.video.enabled) {
for (const auto& stage : stages) {
if (!stage.enabled || !stage.effect->video()) continue;
std::cerr << "effector: skipping " << stage.effect->name()
<< ": a video effect, and this is a still (give --duration or "
"--video-preset for a clip)\n";
}
}
std::vector<effector::LoadFailure> failures;
// Decoding a batch of photographs takes seconds; on a terminal keep a
@@ -91,7 +102,7 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
}
if (sources.empty() && effector::chainNeedsSources(stages)) {
if (sources.empty() && effector::chainNeedsSources(stages, options.video.enabled)) {
std::cerr << "effector: no usable source images\n";
return 2;
}
@@ -108,7 +119,7 @@ int renderHeadless(const effector::Options& options) {
image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
<< " effect=" << effector::chainSlug(stages, false) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0;
}
@@ -129,7 +140,8 @@ int main(int argc, char** argv) {
}
if (options.listEffects) {
for (const auto& effect : effector::effects()) {
std::cout << effect->name() << "\t" << effect->title() << " - "
std::cout << effect->name() << "\t" << effect->title()
<< (effect->video() ? " (video)" : "") << " - "
<< effect->description() << "\n";
}
return 0;
+82 -37
View File
@@ -35,6 +35,7 @@
#include "effects/scanlines.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
#include "effects/video_effect.hpp"
namespace {
@@ -1068,7 +1069,8 @@ int main() {
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
renderChain({scanlines, stage}, sources, 300, 200, b)),
name + " cascades reproducibly");
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
check(!chainNeedsSources({stage, scanlines}, false),
"a chain led by " + name + " needs no sources");
// A canvas one pixel wide or high must not trip any of them up.
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
@@ -1195,8 +1197,8 @@ int main() {
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
bool threw = false;
try {
@@ -1211,10 +1213,10 @@ int main() {
// them, blur only softens what it is handed and runs without any.
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
check(!effectByName("blur")->needsSources(), "blur needs no source images");
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
check(!chainNeedsSources({blur, scanlines}),
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
check(!chainNeedsSources({blur, scanlines}, false),
"a chain led by blur needs none, later stages see the stage before them");
check(!chainNeedsSources({}), "an empty chain needs no sources");
check(!chainNeedsSources({}, false), "an empty chain needs no sources");
Rng bare(5);
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
@@ -1241,12 +1243,12 @@ int main() {
Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
check(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first
// stage still switched on.
check(!chainNeedsSources({scanlinesOff, blur}),
check(!chainNeedsSources({scanlinesOff, blur}, false),
"a disabled leading stage leaves the question to the stage behind it");
Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
@@ -1821,44 +1823,87 @@ int main() {
"a video is recognised by its extension");
const auto split = effector::effectByName("channelsplit");
const auto tracking = effector::effectByName("tracking");
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
// A still ignores every motion param: the picture is the one it
// always was, whatever they are set to.
auto calm = split->defaults();
calm["split_mode"] = std::string("per_band");
auto wild = calm;
wild["split_wobble"] = 40L;
wild["split_jitter"] = 30L;
wild["split_roll"] = 60L;
effector::Rng stillA(5);
effector::Rng stillB(5);
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
"motion params leave a still untouched");
check(tracking->video() && !split->video(),
"VHS tracking is a video effect, channel split an image one");
bool imagesFirst = true;
bool seenVideo = false;
for (const auto& effect : effects()) {
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
seenVideo = seenVideo || effect->video();
}
check(imagesFirst, "the registry lists the image effects before the video effects");
check(split->animated(split->defaults()), "channel split moves by default in a video");
auto frozen = split->defaults();
frozen["split_wobble"] = 0L;
frozen["split_jitter"] = 0L;
frozen["split_roll"] = 0L;
check(!split->animated(frozen), "and holds still with every motion at 0");
auto wild = tracking->defaults();
wild["tracking_wobble"] = 40L;
wild["tracking_jitter"] = 30L;
wild["tracking_bar"] = 60L;
auto frozen = tracking->defaults();
frozen["tracking_wobble"] = 0L;
frozen["tracking_jitter"] = 0L;
frozen["tracking_bar"] = 0L;
// A moving stage leaves the shared rng in the same state on every
// A still skips a video effect whole, as it skips a stage switched
// off: the picture is the one the chain made without it.
ChainStage scanlinesStage{effectByName("scanlines"), {}};
ChainStage trackingStage{tracking, wild};
effector::Rng withRng(5);
effector::Rng withoutRng(5);
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
"a still skips a video effect as though it were not there");
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
"and names it only in a video");
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
!chainNeedsSources({trackingStage, scanlinesStage}, true),
"led by a video effect, a still starts from the stage after it");
bool onlyVideo = false;
try {
effector::Rng rng(1);
renderChain({trackingStage}, sources, 96, 64, rng);
} catch (const EffectError&) {
onlyVideo = true;
}
check(onlyVideo, "a still of video effects alone is refused");
// A video effect leaves the shared rng in the same state on every
// frame, so what comes after it holds still.
effector::Rng frameA(9);
effector::Rng frameB(9);
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
// Time is rounded to fit the clip, so the frame after the last is the
// first again, and a clip loops without a jump.
const effector::Moment start(FrameTime{0, 30, 30}, 1);
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
"a rate is rounded to a whole number of cycles in the clip");
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
"half-way through the clip is half-way through its one cycle");
auto smooth = wild;
smooth["tracking_jitter"] = 0L;
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
effector::Rng loopA(4);
effector::Rng loopB(4);
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
"without the shake, the clip loops without a jump");
effector::Rng stillFrame(4);
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
input[0]),
"with every motion at 0 it leaves its input alone");
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
ChainStage built;
built.effect = effector::effectByName(name);
built.values = values.empty() ? built.effect->defaults() : std::move(values);
return built;
};
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")};
std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
VideoSettings video;
video.enabled = true;
video.duration = 1.0;
@@ -1896,7 +1941,7 @@ int main() {
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
"frames of a moving chain differ");
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")};
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
std::vector<effector::Image> held;
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
@@ -1907,7 +1952,7 @@ int main() {
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
effector::Rng stillRng(21);
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
"a chain that does not move renders its still on every frame");
"a chain of image effects renders its still on every frame");
int seen = 0;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
@@ -1931,7 +1976,7 @@ int main() {
// The project file: a video says so, a still says nothing about time.
effector::Project project;
project.stages = {stage("channelsplit", wild)};
project.stages = {stage("tracking", wild)};
const auto stillJson = effector::projectToJson(project);
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
@@ -1944,8 +1989,8 @@ int main() {
back.video.preset == "instagram-story",
"a video project keeps its length, rate and preset");
const auto motion = back.stages.at(0).values;
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
effector::paramInt(motion, "split_roll", 0) == 60,
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
effector::paramInt(motion, "tracking_bar", 0) == 60,
"and its stages keep their motion");
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
+82 -14
View File
@@ -109,6 +109,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
return nullptr;
}
// Whether any label under `root` says `text`, shown or not.
bool showsText(Gtk::Widget& root, const std::string& text) {
for (auto* label : all<Gtk::Label>(root)) {
if (label->get_text() == text) return true;
}
return false;
}
// Presses a button the way the keyboard does. GTK shows the press for a
// quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) {
@@ -153,6 +161,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
// What can be added to the still project the window opens on.
std::vector<effector::EffectPtr> imageEffects;
for (const auto& effect : effects) {
if (!effect->video()) imageEffects.push_back(effect);
}
std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
@@ -195,6 +208,19 @@ void runTests(Gtk::Window& window) {
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
bool offered = true;
bool videoLast = true;
bool seenVideo = false;
for (const auto& effect : effects) {
auto* entry = menuEntry(*menu, effect->title());
offered = offered && entry->get_sensitive() != effect->video();
videoLast = videoLast && (effect->video() || !seenVideo);
seenVideo = seenVideo || effect->video();
}
check(seenVideo, "there is a video effect");
check(videoLast && showsText(*menu, "Image effects") && showsText(*menu, "Video effects"),
"the menu lists image effects and video effects under headings of their own");
check(offered, "in a still project only the image effects can be picked");
add->popup();
pump();
@@ -204,12 +230,19 @@ void runTests(Gtk::Window& window) {
// there were enough effects: the entries have to scroll instead, in a
// menu short enough to open below a button near the top of a 768-pixel
// screen.
// Image and video effects scroll apart, so the video effects never
// scroll out of sight below a long list of image effects.
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
"the entries scroll");
int cap = 0;
for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
bool scrolled = scrollers.size() == 2;
for (const auto& effect : effects) {
scrolled = scrolled &&
isAncestor(*scrollers[effect->video() ? 1 : 0], *menuEntry(*menu, effect->title()));
}
check(scrolled, "the image and the video entries scroll, each on their own");
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
check(menu->get_height() > 0 && menu->get_height() <= cap + 120,
"and the menu stays within it however many effects there are");
add->popdown();
pump();
@@ -306,15 +339,19 @@ void runTests(Gtk::Window& window) {
}
std::cout << "many stages\n";
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
for (int i = 0; i < 4; ++i) {
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
}
pump();
const int fewSplit = split != nullptr ? split->get_position() : -1;
check(split != nullptr && fewSplit > emptySplit,
"the split moves down as the chain grows");
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
for (int i = 4; i < 12; ++i) {
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
}
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
"including for the last effect in the menu");
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
"including for the last image effect in the menu");
pump();
const int longSplit = split != nullptr ? split->get_position() : -1;
check(longSplit > fewSplit && listScroller != nullptr &&
@@ -455,7 +492,9 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
std::filesystem::create_directories(dir);
const auto path = dir / "moving.json";
effector::Project project;
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources
// Neither needs sources: an image effect, then the video effect.
project.stages.push_back({effector::effectByName("channelsplit"), {}});
project.stages.push_back({effector::effectByName("tracking"), {}});
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
@@ -468,22 +507,49 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
auto* playback = named<Gtk::Box>(window, "playback");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
height != nullptr && playback != nullptr,
"the video switch, its settings and the playback bar are there");
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
height == nullptr || playback == nullptr) {
height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
return;
}
const std::string tracking = effector::effectByName("tracking")->title();
auto* entry = menuEntry(*add->get_popover(), tracking);
// The label of a chain row, which says whether the stage runs.
auto rowLabel = [&](int index) -> Gtk::Label* {
auto* row = chainList->get_row_at_index(index);
if (row == nullptr) return nullptr;
for (auto* label : all<Gtk::Label>(*row)) {
if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label;
}
return nullptr;
};
auto* videoRow = chainList->get_row_at_index(1);
auto* videoLabel = rowLabel(1);
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
"a still project shows no video settings and no playback");
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split");
check(videoRow != nullptr && !all<Gtk::Image>(*videoRow).empty() && videoLabel != nullptr &&
videoLabel->has_css_class("dim-label") &&
videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture",
"a video stage is marked as one, and dimmed as skipped in a still");
check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs");
check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still");
toggle->set_active(true);
pump(200);
videoLabel = rowLabel(1);
check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"),
"in a video the video stage runs");
check(entry != nullptr && entry->get_sensitive(), "and more can be added");
chainList->select_row(*chainList->get_row_at_index(1));
pump(100);
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
"a video shows the channel split's motion params");
"its settings are its motion");
stack->set_visible_child("setup");
pump(100);
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
@@ -511,8 +577,10 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
toggle->set_active(false);
pump(300);
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
"switched back to a still, the playback and motion params go");
videoLabel = rowLabel(1);
check(!playback->get_visible() && videoLabel != nullptr &&
videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(),
"switched back to a still, the playback goes and the video stage is skipped again");
stack->set_visible_child("chain");
}