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 `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
+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 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
View File
@@ -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)
+14 -9
View File
@@ -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).
+1 -1
View File
@@ -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')
+16 -9
View File
@@ -264,15 +264,22 @@ std::string usage(const char* programName) {
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n"; out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
} }
for (const auto& effect : effects()) { // Image effects first, then the video effects under a heading of their
out << "\n" << effect->title() << " options:\n"; // own: those run only in a clip.
for (const auto& spec : effect->params()) { for (const bool video : {false, true}) {
std::string flag = spec.flag(); if (video) {
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE"; out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
out << " " << flag; }
if (flag.size() < 20) out << std::string(20 - flag.size(), ' '); for (const auto& effect : effects()) {
out << " " << (spec.help.empty() ? spec.label : spec.help) if (effect->video() != video) continue;
<< (spec.videoOnly ? " (video only)" : "") << "\n"; 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(); return out.str();
+29 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-6
View File
@@ -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;
-7
View File
@@ -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);
+3
View File
@@ -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;
} }
+8 -172
View File
@@ -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
View File
@@ -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
+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 // 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,39 +1711,86 @@ 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 auto& effect : effects_) { for (const bool video : {false, true}) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title()); if (video) {
item->add_css_class("flat"); auto* separator = Gtk::make_managed<Gtk::Separator>();
item->set_tooltip_text(effect->description()); separator->set_margin_top(6);
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F); separator->set_margin_bottom(4);
item->signal_clicked().connect([this, effect] { sections->append(*separator);
addEffectButton_.popdown(); }
appendStage(effect); auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
}); heading->set_margin_start(8);
list->append(*item); 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 popover->set_child(*sections);
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
return *popover; return *popover;
} }
// 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);
+5
View File
@@ -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
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 << " " 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
View File
@@ -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
View File
@@ -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");
} }