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