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