Add Strip roll, a video effect that rolls the picture in Scanlines' strips

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:43:47 +02:00
co-authored by Claude Opus 5.5
parent a7cb2505d7
commit 4b407281e2
6 changed files with 275 additions and 0 deletions
+5
View File
@@ -36,6 +36,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
block's picture smears along while it shows. On the command line it is
`-e datamosh` with `--mosh-rate`, `--mosh-hold`, `--mosh-drift`,
`--mosh-speed` and the rest.
- Strip roll, a video effect: the canvas cut into strips of the photographs
as Scanlines cuts it, and the picture in every strip rolling up or down
through its image, or sliding sideways, while the strips stay put. On the
command line it is `-e striproll` with `--roll-speed`, `--roll-axis`,
`--roll-direction` and the rest.
- A Quality choice under Video on Setup: Small file, Standard or High, for
how the saved video is encoded. It is kept in the project, and on the
command line it is `--video-quality small|standard|high`. Standard writes
+1
View File
@@ -57,6 +57,7 @@ add_library(effector_core STATIC
src/effects/scanlines.cpp
src/effects/shatter.cpp
src/effects/slitshift.cpp
src/effects/striproll.cpp
src/effects/sweep.cpp
src/effects/text.cpp
src/effects/tiles.cpp
+2
View File
@@ -15,6 +15,7 @@
#include "effects/scanlines.hpp"
#include "effects/shatter.hpp"
#include "effects/slitshift.hpp"
#include "effects/striproll.hpp"
#include "effects/sweep.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
@@ -51,6 +52,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<SweepEffect>(),
std::make_shared<FilmGateEffect>(),
std::make_shared<DatamoshEffect>(),
std::make_shared<StripRollEffect>(),
};
return registry;
}
+143
View File
@@ -0,0 +1,143 @@
#include "effects/striproll.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& StripRollEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("roll_height", "Strip height", 24, 1, 512,
"Height in pixels of each strip."),
ParamSpec::makeInt("roll_jitter", "Height jitter", 0, 0, 256,
"Random +/- variation added to the strip height (0 = uniform)."),
ParamSpec::makeBool("roll_keep_order", "Cycle images in order", false,
"Take strips from the images in order instead of picking randomly."),
ParamSpec::makeChoice("roll_axis", "Axis", "vertical", {"vertical", "horizontal"},
"vertical rolls the picture in each strip up or down through its "
"image; horizontal slides it sideways."),
ParamSpec::makeChoice("roll_direction", "Direction", "alternate",
{"alternate", "same", "random"},
"alternate rolls every other strip the other way; same rolls them "
"all one way; random picks for each strip."),
ParamSpec::makeInt("roll_speed", "Speed", 120, 0, 8192,
"Pixels per second the picture rolls, rounded so that each strip goes "
"round its image a whole number of times in the clip; 0 holds still."),
};
return specs;
}
namespace {
struct Settings {
int stripHeight;
int jitter;
bool keepOrder;
bool horizontal;
std::string direction;
int speed;
explicit Settings(const ParamValues& values)
: stripHeight(static_cast<int>(std::max(1L, paramInt(values, "roll_height", 24)))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "roll_jitter", 0)))),
keepOrder(paramBool(values, "roll_keep_order", false)),
horizontal(paramChoice(values, "roll_axis", "vertical") == "horizontal"),
direction(paramChoice(values, "roll_direction", "alternate")),
speed(static_cast<int>(std::max(0L, paramInt(values, "roll_speed", 120)))) {}
};
// One strip of the canvas: where it lies, the image it shows, where in that
// image its picture starts, and which way it rolls.
struct Strip {
int dstY;
int height;
std::size_t sourceIndex;
int sx;
int sy;
int way; // +1 or -1
};
int wrap(long value, int span) { return static_cast<int>((value % span + span) % span); }
} // namespace
Image StripRollEffect::compose(const std::vector<Image>& sources, int width, int height,
const ParamValues& values, const Moment& at) const {
const Settings settings(values);
// What stays put for the whole clip: the strips, drawn as Scanlines draws
// them, and the way each one rolls.
Rng steady = at.steady();
std::vector<Strip> strips;
strips.reserve(static_cast<std::size_t>(height / settings.stripHeight) + 8);
for (int y = 0; y < height;) {
Strip strip{};
strip.dstY = y;
strip.sourceIndex = settings.keepOrder ? strips.size() % sources.size()
: steady.below(sources.size() - 1);
const Image& src = sources[strip.sourceIndex];
int h = settings.stripHeight;
if (settings.jitter > 0) {
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
}
strip.height = std::max(1, std::min(h, height - y));
strip.sy = static_cast<int>(steady.inRange(0, src.height() - 1));
strip.sx = static_cast<int>(steady.inRange(0, src.width() - 1));
if (settings.direction == "random") {
strip.way = (steady.next() >> 63) != 0 ? 1 : -1;
} else if (settings.direction == "alternate") {
strip.way = strips.size() % 2 == 0 ? 1 : -1;
} else {
strip.way = 1;
}
strips.push_back(strip);
y += strip.height;
}
// Where this frame is in time: how far round its image each strip has
// rolled, a whole number of rounds in the clip, the nearest to the speed.
std::vector<long> offsets(strips.size(), 0);
if (settings.speed > 0) {
for (std::size_t i = 0; i < strips.size(); ++i) {
const Image& src = sources[strips[i].sourceIndex];
const int span = settings.horizontal ? src.width() : src.height();
const double rounds = std::max(1.0, std::round(settings.speed * at.length() / span));
const double into = std::fmod(at.seconds() * rounds / at.length(), 1.0);
offsets[i] = strips[i].way * std::lround(into * span);
}
}
Image canvas(width, height);
parallelFor(strips.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
const Strip& strip = strips[i];
const Image& src = sources[strip.sourceIndex];
const long down = settings.horizontal ? 0 : offsets[i];
const long across = settings.horizontal ? offsets[i] : 0;
const int startX = wrap(strip.sx + across, src.width());
for (int line = 0; line < strip.height; ++line) {
const std::uint8_t* in = src.row(wrap(strip.sy + line + down, src.height()));
std::uint8_t* out = canvas.row(strip.dstY + line);
// Round the image's width as often as the canvas needs.
int x = 0;
int from = startX;
while (x < width) {
const int run = std::min(width - x, src.width() - from);
std::memcpy(out + static_cast<std::size_t>(x) * 3,
in + static_cast<std::size_t>(from) * 3,
static_cast<std::size_t>(run) * 3);
x += run;
from = 0;
}
}
}
});
return canvas;
}
} // namespace effector
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// The scanlines effect in motion: the canvas is cut into strips as Scanlines
// cuts it, each showing a slice of one of the source images, and the picture
// in every strip rolls through its image -- up or down, or sideways -- while
// the strips themselves stay put.
//
// Built from the sources, as TileSwapEffect is: it answers needsSources() and
// writes compose(). A strip goes round its image a whole number of times in
// the clip, so the clip loops without a jump.
class StripRollEffect : public VideoEffect {
public:
std::string name() const override { return "striproll"; }
std::string title() const override { return "Strip roll"; }
std::string description() const override {
return "Cuts the canvas into strips of the source images, as Scanlines does, and rolls "
"the picture in each strip through its image.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return true; }
protected:
Image compose(const std::vector<Image>& sources, int width, int height,
const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+93
View File
@@ -2095,6 +2095,99 @@ int main() {
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
}
// Strip roll builds its frames out of the sources too, and rolls the
// picture in each strip through its image.
{
const auto roll = effectByName("striproll");
check(roll->video() && roll->needsSources(),
"strip roll is a video effect built from the sources");
ChainStage rollStage = stage("striproll", {{"roll_height", 6L}, {"roll_speed", 40L}});
ChainStage scanStage = stage("scanlines");
effector::Rng withRoll(3);
effector::Rng withoutRoll(3);
check(sameBytes(renderChain({rollStage, scanStage}, sources, 80, 48, withRoll),
renderChain({scanStage}, sources, 80, 48, withoutRoll)),
"a still skips strip roll as though it were not there");
bool refused = false;
try {
effector::Rng rng(1);
renderChain({rollStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
} catch (const EffectError&) {
refused = true;
}
check(refused, "strip roll with no sources is refused");
const std::vector<effector::Image> photos = {gradient(60, 40, 20), gradient(50, 30, 150)};
auto frameOf = [&](const ChainStage& at, int index) {
effector::Rng rng(11);
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, 8, 8});
};
effector::Rng rollA(9);
effector::Rng rollB(9);
roll->renderFrame(photos, 80, 48, rollA, rollStage.values, FrameTime{3, 8, 8});
roll->renderFrame(photos, 80, 48, rollB, rollStage.values, FrameTime{5, 8, 8});
check(rollA.next() == rollB.next(), "strip roll leaves the shared rng in one state");
// Every row of a strip is a row of one photograph, turned round.
auto rowsOfPhotos = [&](const effector::Image& image) {
for (int y = 0; y < image.height(); ++y) {
bool found = false;
for (const auto& photo : photos) {
for (int py = 0; !found && py < photo.height(); ++py) {
for (int shift = 0; !found && shift < photo.width(); ++shift) {
bool same = true;
for (int x = 0; same && x < image.width(); ++x) {
const int px = (x + shift) % photo.width();
same = std::equal(image.row(y) + x * 3, image.row(y) + x * 3 + 3,
photo.row(py) + px * 3);
}
found = same;
}
}
}
if (!found) return false;
}
return true;
};
check(rowsOfPhotos(frameOf(rollStage, 0)) && rowsOfPhotos(frameOf(rollStage, 5)),
"its strips are rows of the photographs");
bool rolled = false;
for (int index = 1; index < 8; ++index) {
rolled = rolled || !sameBytes(frameOf(rollStage, 0), frameOf(rollStage, index));
}
check(rolled, "its strips roll over the clip");
check(sameBytes(frameOf(rollStage, 0), frameOf(rollStage, 8)),
"and the frame after the last is the first again");
ChainStage sideways = rollStage;
sideways.values["roll_axis"] = std::string("horizontal");
sideways.values["roll_direction"] = std::string("random");
sideways.values["roll_keep_order"] = true;
check(rowsOfPhotos(frameOf(sideways, 3)) &&
!sameBytes(frameOf(sideways, 0), frameOf(sideways, 3)) &&
sameBytes(frameOf(sideways, 0), frameOf(sideways, 8)),
"sideways, its strips slide along their rows and loop");
ChainStage frozenRoll = rollStage;
frozenRoll.values["roll_speed"] = 0L;
check(sameBytes(frameOf(frozenRoll, 0), frameOf(frozenRoll, 5)),
"at a speed of 0 it holds still");
const std::vector<ChainStage> rollChain = {rollStage, stage("grain")};
std::vector<effector::Image> clip;
effector::renderFrames(rollChain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
clip.push_back(std::move(frame));
return true;
});
bool alone = clip.size() == 8;
for (const int index : {0, 4, 7}) {
effector::Rng rng(21);
alone = alone && sameBytes(effector::renderChain(rollChain, sources, 80, 48, rng,
{}, FrameTime{index, 8, 8}),
clip[static_cast<std::size_t>(index)]);
}
check(alone, "a strip roll clip's frame is exactly that frame rendered on its own");
}
// The video transforms made from image effects. Each gets a setting
// that moves plenty, one without its per-frame noise (which, drawn
// anew on every frame, is the one thing that does not loop), and one