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
+82 -14
View File
@@ -109,6 +109,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
return nullptr;
}
// Whether any label under `root` says `text`, shown or not.
bool showsText(Gtk::Widget& root, const std::string& text) {
for (auto* label : all<Gtk::Label>(root)) {
if (label->get_text() == text) return true;
}
return false;
}
// Presses a button the way the keyboard does. GTK shows the press for a
// quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) {
@@ -153,6 +161,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
// What can be added to the still project the window opens on.
std::vector<effector::EffectPtr> imageEffects;
for (const auto& effect : effects) {
if (!effect->video()) imageEffects.push_back(effect);
}
std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
@@ -195,6 +208,19 @@ void runTests(Gtk::Window& window) {
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
bool offered = true;
bool videoLast = true;
bool seenVideo = false;
for (const auto& effect : effects) {
auto* entry = menuEntry(*menu, effect->title());
offered = offered && entry->get_sensitive() != effect->video();
videoLast = videoLast && (effect->video() || !seenVideo);
seenVideo = seenVideo || effect->video();
}
check(seenVideo, "there is a video effect");
check(videoLast && showsText(*menu, "Image effects") && showsText(*menu, "Video effects"),
"the menu lists image effects and video effects under headings of their own");
check(offered, "in a still project only the image effects can be picked");
add->popup();
pump();
@@ -204,12 +230,19 @@ void runTests(Gtk::Window& window) {
// there were enough effects: the entries have to scroll instead, in a
// menu short enough to open below a button near the top of a 768-pixel
// screen.
// Image and video effects scroll apart, so the video effects never
// scroll out of sight below a long list of image effects.
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
"the entries scroll");
int cap = 0;
for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
bool scrolled = scrollers.size() == 2;
for (const auto& effect : effects) {
scrolled = scrolled &&
isAncestor(*scrollers[effect->video() ? 1 : 0], *menuEntry(*menu, effect->title()));
}
check(scrolled, "the image and the video entries scroll, each on their own");
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
check(menu->get_height() > 0 && menu->get_height() <= cap + 120,
"and the menu stays within it however many effects there are");
add->popdown();
pump();
@@ -306,15 +339,19 @@ void runTests(Gtk::Window& window) {
}
std::cout << "many stages\n";
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
for (int i = 0; i < 4; ++i) {
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
}
pump();
const int fewSplit = split != nullptr ? split->get_position() : -1;
check(split != nullptr && fewSplit > emptySplit,
"the split moves down as the chain grows");
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
for (int i = 4; i < 12; ++i) {
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
}
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
"including for the last effect in the menu");
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
"including for the last image effect in the menu");
pump();
const int longSplit = split != nullptr ? split->get_position() : -1;
check(longSplit > fewSplit && listScroller != nullptr &&
@@ -455,7 +492,9 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
std::filesystem::create_directories(dir);
const auto path = dir / "moving.json";
effector::Project project;
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources
// Neither needs sources: an image effect, then the video effect.
project.stages.push_back({effector::effectByName("channelsplit"), {}});
project.stages.push_back({effector::effectByName("tracking"), {}});
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
@@ -468,22 +507,49 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
auto* playback = named<Gtk::Box>(window, "playback");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
height != nullptr && playback != nullptr,
"the video switch, its settings and the playback bar are there");
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
height == nullptr || playback == nullptr) {
height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
return;
}
const std::string tracking = effector::effectByName("tracking")->title();
auto* entry = menuEntry(*add->get_popover(), tracking);
// The label of a chain row, which says whether the stage runs.
auto rowLabel = [&](int index) -> Gtk::Label* {
auto* row = chainList->get_row_at_index(index);
if (row == nullptr) return nullptr;
for (auto* label : all<Gtk::Label>(*row)) {
if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label;
}
return nullptr;
};
auto* videoRow = chainList->get_row_at_index(1);
auto* videoLabel = rowLabel(1);
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
"a still project shows no video settings and no playback");
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split");
check(videoRow != nullptr && !all<Gtk::Image>(*videoRow).empty() && videoLabel != nullptr &&
videoLabel->has_css_class("dim-label") &&
videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture",
"a video stage is marked as one, and dimmed as skipped in a still");
check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs");
check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still");
toggle->set_active(true);
pump(200);
videoLabel = rowLabel(1);
check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"),
"in a video the video stage runs");
check(entry != nullptr && entry->get_sensitive(), "and more can be added");
chainList->select_row(*chainList->get_row_at_index(1));
pump(100);
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
"a video shows the channel split's motion params");
"its settings are its motion");
stack->set_visible_child("setup");
pump(100);
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
@@ -511,8 +577,10 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
toggle->set_active(false);
pump(300);
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
"switched back to a still, the playback and motion params go");
videoLabel = rowLabel(1);
check(!playback->get_visible() && videoLabel != nullptr &&
videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(),
"switched back to a still, the playback goes and the video stage is skipped again");
stack->set_visible_child("chain");
}