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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user