diff --git a/CHANGELOG.md b/CHANGELOG.md index e526175..e26aa24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 2db2d02..8afae33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/core/registry.cpp b/src/core/registry.cpp index ead829b..6d02d83 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.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& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/striproll.cpp b/src/effects/striproll.cpp new file mode 100644 index 0000000..4a5768b --- /dev/null +++ b/src/effects/striproll.cpp @@ -0,0 +1,143 @@ +#include "effects/striproll.hpp" + +#include +#include +#include +#include +#include + +#include "core/parallel.hpp" + +namespace effector { + +const std::vector& StripRollEffect::params() const { + static const std::vector 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(std::max(1L, paramInt(values, "roll_height", 24)))), + jitter(static_cast(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(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((value % span + span) % span); } + +} // namespace + +Image StripRollEffect::compose(const std::vector& 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 strips; + strips.reserve(static_cast(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(steady.inRange(-settings.jitter, settings.jitter)); + } + strip.height = std::max(1, std::min(h, height - y)); + strip.sy = static_cast(steady.inRange(0, src.height() - 1)); + strip.sx = static_cast(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 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(x) * 3, + in + static_cast(from) * 3, + static_cast(run) * 3); + x += run; + from = 0; + } + } + } + }); + return canvas; +} + +} // namespace effector diff --git a/src/effects/striproll.hpp b/src/effects/striproll.hpp new file mode 100644 index 0000000..f689d4e --- /dev/null +++ b/src/effects/striproll.hpp @@ -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& params() const override; + bool needsSources() const override { return true; } + +protected: + Image compose(const std::vector& sources, int width, int height, + const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 17242c3..04853e6 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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 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 rollChain = {rollStage, stage("grain")}; + std::vector 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(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