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:
+82
-37
@@ -35,6 +35,7 @@
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -1068,7 +1069,8 @@ int main() {
|
||||
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
||||
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
||||
name + " cascades reproducibly");
|
||||
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
|
||||
check(!chainNeedsSources({stage, scanlines}, false),
|
||||
"a chain led by " + name + " needs no sources");
|
||||
|
||||
// A canvas one pixel wide or high must not trip any of them up.
|
||||
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
||||
@@ -1195,8 +1197,8 @@ int main() {
|
||||
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||
|
||||
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
||||
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
@@ -1211,10 +1213,10 @@ int main() {
|
||||
// them, blur only softens what it is handed and runs without any.
|
||||
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}),
|
||||
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}, false),
|
||||
"a chain led by blur needs none, later stages see the stage before them");
|
||||
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
||||
check(!chainNeedsSources({}, false), "an empty chain needs no sources");
|
||||
|
||||
Rng bare(5);
|
||||
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||
@@ -1241,12 +1243,12 @@ int main() {
|
||||
Rng skipping(5);
|
||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
||||
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
||||
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
||||
check(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
|
||||
|
||||
// Whoever leads the chain is handed the sources, and that is the first
|
||||
// stage still switched on.
|
||||
check(!chainNeedsSources({scanlinesOff, blur}),
|
||||
check(!chainNeedsSources({scanlinesOff, blur}, false),
|
||||
"a disabled leading stage leaves the question to the stage behind it");
|
||||
Rng leading(5);
|
||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||
@@ -1821,44 +1823,87 @@ int main() {
|
||||
"a video is recognised by its extension");
|
||||
|
||||
const auto split = effector::effectByName("channelsplit");
|
||||
const auto tracking = effector::effectByName("tracking");
|
||||
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||
|
||||
// A still ignores every motion param: the picture is the one it
|
||||
// always was, whatever they are set to.
|
||||
auto calm = split->defaults();
|
||||
calm["split_mode"] = std::string("per_band");
|
||||
auto wild = calm;
|
||||
wild["split_wobble"] = 40L;
|
||||
wild["split_jitter"] = 30L;
|
||||
wild["split_roll"] = 60L;
|
||||
effector::Rng stillA(5);
|
||||
effector::Rng stillB(5);
|
||||
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
|
||||
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
|
||||
"motion params leave a still untouched");
|
||||
check(tracking->video() && !split->video(),
|
||||
"VHS tracking is a video effect, channel split an image one");
|
||||
bool imagesFirst = true;
|
||||
bool seenVideo = false;
|
||||
for (const auto& effect : effects()) {
|
||||
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
|
||||
seenVideo = seenVideo || effect->video();
|
||||
}
|
||||
check(imagesFirst, "the registry lists the image effects before the video effects");
|
||||
|
||||
check(split->animated(split->defaults()), "channel split moves by default in a video");
|
||||
auto frozen = split->defaults();
|
||||
frozen["split_wobble"] = 0L;
|
||||
frozen["split_jitter"] = 0L;
|
||||
frozen["split_roll"] = 0L;
|
||||
check(!split->animated(frozen), "and holds still with every motion at 0");
|
||||
auto wild = tracking->defaults();
|
||||
wild["tracking_wobble"] = 40L;
|
||||
wild["tracking_jitter"] = 30L;
|
||||
wild["tracking_bar"] = 60L;
|
||||
auto frozen = tracking->defaults();
|
||||
frozen["tracking_wobble"] = 0L;
|
||||
frozen["tracking_jitter"] = 0L;
|
||||
frozen["tracking_bar"] = 0L;
|
||||
|
||||
// A moving stage leaves the shared rng in the same state on every
|
||||
// A still skips a video effect whole, as it skips a stage switched
|
||||
// off: the picture is the one the chain made without it.
|
||||
ChainStage scanlinesStage{effectByName("scanlines"), {}};
|
||||
ChainStage trackingStage{tracking, wild};
|
||||
effector::Rng withRng(5);
|
||||
effector::Rng withoutRng(5);
|
||||
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
|
||||
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
|
||||
"a still skips a video effect as though it were not there");
|
||||
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
|
||||
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
|
||||
"and names it only in a video");
|
||||
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
|
||||
!chainNeedsSources({trackingStage, scanlinesStage}, true),
|
||||
"led by a video effect, a still starts from the stage after it");
|
||||
bool onlyVideo = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({trackingStage}, sources, 96, 64, rng);
|
||||
} catch (const EffectError&) {
|
||||
onlyVideo = true;
|
||||
}
|
||||
check(onlyVideo, "a still of video effects alone is refused");
|
||||
|
||||
// A video effect leaves the shared rng in the same state on every
|
||||
// frame, so what comes after it holds still.
|
||||
effector::Rng frameA(9);
|
||||
effector::Rng frameB(9);
|
||||
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
||||
|
||||
// Time is rounded to fit the clip, so the frame after the last is the
|
||||
// first again, and a clip loops without a jump.
|
||||
const effector::Moment start(FrameTime{0, 30, 30}, 1);
|
||||
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
|
||||
"a rate is rounded to a whole number of cycles in the clip");
|
||||
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
|
||||
"half-way through the clip is half-way through its one cycle");
|
||||
auto smooth = wild;
|
||||
smooth["tracking_jitter"] = 0L;
|
||||
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
|
||||
effector::Rng loopA(4);
|
||||
effector::Rng loopB(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
|
||||
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
|
||||
"without the shake, the clip loops without a jump");
|
||||
effector::Rng stillFrame(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
|
||||
input[0]),
|
||||
"with every motion at 0 it leaves its input alone");
|
||||
|
||||
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
|
||||
ChainStage built;
|
||||
built.effect = effector::effectByName(name);
|
||||
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
||||
return built;
|
||||
};
|
||||
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")};
|
||||
std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
|
||||
VideoSettings video;
|
||||
video.enabled = true;
|
||||
video.duration = 1.0;
|
||||
@@ -1896,7 +1941,7 @@ int main() {
|
||||
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
||||
"frames of a moving chain differ");
|
||||
|
||||
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")};
|
||||
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
|
||||
std::vector<effector::Image> held;
|
||||
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
@@ -1907,7 +1952,7 @@ int main() {
|
||||
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
||||
effector::Rng stillRng(21);
|
||||
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
|
||||
"a chain that does not move renders its still on every frame");
|
||||
"a chain of image effects renders its still on every frame");
|
||||
|
||||
int seen = 0;
|
||||
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||
@@ -1931,7 +1976,7 @@ int main() {
|
||||
|
||||
// The project file: a video says so, a still says nothing about time.
|
||||
effector::Project project;
|
||||
project.stages = {stage("channelsplit", wild)};
|
||||
project.stages = {stage("tracking", wild)};
|
||||
const auto stillJson = effector::projectToJson(project);
|
||||
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
|
||||
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
|
||||
@@ -1944,8 +1989,8 @@ int main() {
|
||||
back.video.preset == "instagram-story",
|
||||
"a video project keeps its length, rate and preset");
|
||||
const auto motion = back.stages.at(0).values;
|
||||
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
|
||||
effector::paramInt(motion, "split_roll", 0) == 60,
|
||||
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
|
||||
effector::paramInt(motion, "tracking_bar", 0) == 60,
|
||||
"and its stages keep their motion");
|
||||
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
||||
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
||||
|
||||
+82
-14
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user