diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c09c30..b7055e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ commit that made it. The interface shows this file under Changelog in its menu. of noise as Warp does, with the field drifting so the picture ripples like heat haze or water. On the command line it is `-e flow` with `--flow-strength`, `--flow-travel`, `--flow-speed` and the rest. +- Sort sweep, a video effect: runs of pixels sorted as Pixel sort sorts + them, only within a window of brightness that sweeps from dark to bright + and back, so the streaks spread over one part of the picture after + another. On the command line it is `-e sweep` with `--sweep-from`, + `--sweep-to`, `--sweep-width`, `--sweep-speed` 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 3c73f47..66d8394 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,7 @@ add_library(effector_core STATIC src/effects/scanlines.cpp src/effects/shatter.cpp src/effects/slitshift.cpp + src/effects/sweep.cpp src/effects/text.cpp src/effects/tiles.cpp src/effects/tileswap.cpp diff --git a/src/core/registry.cpp b/src/core/registry.cpp index afcdc50..beb285d 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -13,6 +13,7 @@ #include "effects/scanlines.hpp" #include "effects/shatter.hpp" #include "effects/slitshift.hpp" +#include "effects/sweep.hpp" #include "effects/text.hpp" #include "effects/tiles.hpp" #include "effects/tileswap.hpp" @@ -45,6 +46,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/pixelsort.cpp b/src/effects/pixelsort.cpp index 9881a1b..b44ad75 100644 --- a/src/effects/pixelsort.cpp +++ b/src/effects/pixelsort.cpp @@ -7,6 +7,7 @@ #include "core/parallel.hpp" #include "effects/sampling.hpp" +#include "effects/sort_runs.hpp" #include "effects/source_fill.hpp" namespace effector { @@ -34,39 +35,6 @@ const std::vector& PixelSortEffect::params() const { namespace { -int luma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; } - -// Hue on a 0..1535 scale (six sectors of 256), in integers. -int hue(const std::uint8_t* px) { - const int r = px[0], g = px[1], b = px[2]; - const int hi = std::max({r, g, b}); - const int lo = std::min({r, g, b}); - const int range = hi - lo; - if (range == 0) return 0; - if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range; - if (hi == g) return 512 + (b - r) * 255 / range; - return 1024 + (r - g) * 255 / range; -} - -int saturation(const std::uint8_t* px) { - return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]}); -} - -// The image with rows and columns swapped, so vertical sorting is horizontal -// sorting of the transpose. -Image transposed(const Image& src) { - Image out(src.height(), src.width()); - parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { - for (std::size_t x = begin; x < end; ++x) { - std::uint8_t* row = out.row(static_cast(x)); - for (int y = 0; y < src.height(); ++y) { - std::memcpy(row + static_cast(y) * 3, src.row(y) + x * 3, 3); - } - } - }); - return out; -} - struct LinePlan { bool sorted = false; std::uint64_t seed = 0; // for the run lengths, when they are limited @@ -99,49 +67,19 @@ Image PixelSortEffect::render(const std::vector& sources, int width, int if (maxRun > 0) line.seed = rng.next(); } - auto keyOf = [&](const std::uint8_t* px) { - if (keyName == "hue") return hue(px); - if (keyName == "saturation") return saturation(px); - return luma(px); - }; + SortRule rule; + rule.low = low; + rule.high = high; + rule.key = sortKeyNamed(keyName); + rule.reverse = reverse; + rule.maxRun = maxRun; parallelFor(static_cast(lines), [&](std::size_t begin, std::size_t end) { std::vector>> run; for (std::size_t line = begin; line < end; ++line) { if (!plan[line].sorted) continue; Rng runRng(plan[line].seed); - std::uint8_t* row = canvas.row(static_cast(line)); - - auto flush = [&](int start) { - // A stable sort, so equal keys keep their order on every - // standard library. - std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) { - return reverse ? a.first > b.first : a.first < b.first; - }); - for (std::size_t i = 0; i < run.size(); ++i) { - std::memcpy(row + (static_cast(start) + i) * 3, run[i].second.data(), 3); - } - run.clear(); - }; - - int start = 0; - int limit = maxRun > 0 ? static_cast(runRng.inRange(1, maxRun)) : span; - for (int x = 0; x < span; ++x) { - const std::uint8_t* px = row + static_cast(x) * 3; - const int brightness = luma(px); - if (brightness < low || brightness > high) { - flush(start); - start = x + 1; - continue; - } - if (static_cast(run.size()) >= limit) { - flush(start); - start = x; - if (maxRun > 0) limit = static_cast(runRng.inRange(1, maxRun)); - } - run.push_back({keyOf(px), {px[0], px[1], px[2]}}); - } - flush(start); + sortLine(canvas.row(static_cast(line)), span, rule, runRng, run); } }); diff --git a/src/effects/sort_runs.hpp b/src/effects/sort_runs.hpp new file mode 100644 index 0000000..8fc2c0b --- /dev/null +++ b/src/effects/sort_runs.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/image.hpp" +#include "core/parallel.hpp" +#include "core/rng.hpp" + +namespace effector { + +// What orders the pixels inside a run. Shared by the pixel sort and the sort +// sweep effects. +enum class SortKey { Luma, Hue, Saturation }; + +inline SortKey sortKeyNamed(const std::string& name) { + if (name == "hue") return SortKey::Hue; + if (name == "saturation") return SortKey::Saturation; + return SortKey::Luma; +} + +inline int sortLuma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; } + +// Hue on a 0..1535 scale (six sectors of 256), in integers. +inline int sortHue(const std::uint8_t* px) { + const int r = px[0], g = px[1], b = px[2]; + const int hi = std::max({r, g, b}); + const int lo = std::min({r, g, b}); + const int range = hi - lo; + if (range == 0) return 0; + if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range; + if (hi == g) return 512 + (b - r) * 255 / range; + return 1024 + (r - g) * 255 / range; +} + +inline int sortSaturation(const std::uint8_t* px) { + return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]}); +} + +inline int sortKeyOf(SortKey key, const std::uint8_t* px) { + switch (key) { + case SortKey::Hue: return sortHue(px); + case SortKey::Saturation: return sortSaturation(px); + case SortKey::Luma: break; + } + return sortLuma(px); +} + +// The image with rows and columns swapped, so vertical sorting is horizontal +// sorting of the transpose. +inline Image transposed(const Image& src) { + Image out(src.height(), src.width()); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + std::uint8_t* row = out.row(static_cast(x)); + for (int y = 0; y < src.height(); ++y) { + std::memcpy(row + static_cast(y) * 3, src.row(y) + x * 3, 3); + } + } + }); + return out; +} + +// How one line is sorted: the brightness a pixel must lie within (0..255) to +// join a run, what orders a run, and the longest a run may grow (0 for no +// limit), its lengths drawn from `runRng`. +struct SortRule { + int low = 0; + int high = 255; + SortKey key = SortKey::Luma; + bool reverse = false; + int maxRun = 0; +}; + +// Sorts every run of one line of `span` pixels in place. `run` is scratch +// space, reused from line to line. +inline void sortLine(std::uint8_t* row, int span, const SortRule& rule, Rng& runRng, + std::vector>>& run) { + auto flush = [&](int start) { + // A stable sort, so equal keys keep their order on every standard + // library. + std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) { + return rule.reverse ? a.first > b.first : a.first < b.first; + }); + for (std::size_t i = 0; i < run.size(); ++i) { + std::memcpy(row + (static_cast(start) + i) * 3, run[i].second.data(), 3); + } + run.clear(); + }; + + int start = 0; + int limit = rule.maxRun > 0 ? static_cast(runRng.inRange(1, rule.maxRun)) : span; + for (int x = 0; x < span; ++x) { + const std::uint8_t* px = row + static_cast(x) * 3; + const int brightness = sortLuma(px); + if (brightness < rule.low || brightness > rule.high) { + flush(start); + start = x + 1; + continue; + } + if (static_cast(run.size()) >= limit) { + flush(start); + start = x; + if (rule.maxRun > 0) limit = static_cast(runRng.inRange(1, rule.maxRun)); + } + run.push_back({sortKeyOf(rule.key, px), {px[0], px[1], px[2]}}); + } + flush(start); +} + +} // namespace effector diff --git a/src/effects/sweep.cpp b/src/effects/sweep.cpp new file mode 100644 index 0000000..98f68ca --- /dev/null +++ b/src/effects/sweep.cpp @@ -0,0 +1,115 @@ +#include "effects/sweep.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/sampling.hpp" +#include "effects/sort_runs.hpp" + +namespace effector { + +const std::vector& SweepEffect::params() const { + static const std::vector specs = { + ParamSpec::makeDouble("sweep_from", "From", 0.1, 0.0, 1.0, + "Brightness the middle of the window starts the sweep at."), + ParamSpec::makeDouble("sweep_to", "To", 0.9, 0.0, 1.0, + "Brightness the middle of the window turns back at."), + ParamSpec::makeDouble("sweep_width", "Window", 0.35, 0.0, 1.0, + "How wide a band of brightness is sorted at once; pixels outside " + "it break a run and stay where they are."), + ParamSpec::makeDouble("sweep_speed", "Speed", 0.25, 0.0, 10.0, + "Sweeps there and back per second, rounded so that the clip loops " + "without a jump; 0 holds still.", + 0.05, 2), + ParamSpec::makeChoice("sweep_key", "Sort by", "luma", {"luma", "hue", "saturation"}, + "What orders the pixels inside a run."), + ParamSpec::makeChoice("sweep_direction", "Direction", "horizontal", + {"horizontal", "vertical"}, "Whether rows or columns are sorted."), + ParamSpec::makeBool("sweep_reverse", "Reverse", false, + "Sort from high to low instead of from low to high."), + ParamSpec::makeDouble("sweep_line_chance", "Line chance", 1.0, 0.0, 1.0, + "Chance of each row (or column) being sorted at all."), + ParamSpec::makeInt("sweep_max_run", "Longest run", 0, 0, 8192, + "Each run is cut after a random length up to this many pixels, which " + "frays the edges of the sorted streaks (0 = no limit)."), + }; + return specs; +} + +namespace { + +struct Settings { + double from; + double to; + double width; + double speed; + SortKey key; + bool vertical; + bool reverse; + double lineChance; + int maxRun; + + explicit Settings(const ParamValues& values) + : from(std::clamp(paramDouble(values, "sweep_from", 0.1), 0.0, 1.0)), + to(std::clamp(paramDouble(values, "sweep_to", 0.9), 0.0, 1.0)), + width(std::clamp(paramDouble(values, "sweep_width", 0.35), 0.0, 1.0)), + speed(std::max(0.0, paramDouble(values, "sweep_speed", 0.25))), + key(sortKeyNamed(paramChoice(values, "sweep_key", "luma"))), + vertical(paramChoice(values, "sweep_direction", "horizontal") == "vertical"), + reverse(paramBool(values, "sweep_reverse", false)), + lineChance(std::clamp(paramDouble(values, "sweep_line_chance", 1.0), 0.0, 1.0)), + maxRun(static_cast(std::max(0L, paramInt(values, "sweep_max_run", 0)))) {} +}; + +struct LinePlan { + bool sorted = false; + std::uint64_t seed = 0; // for the run lengths, the same on every frame +}; + +} // namespace + +Image SweepEffect::animate(Image input, const ParamValues& values, const Moment& at) const { + const Settings settings(values); + Image canvas = settings.vertical ? transposed(input) : std::move(input); + const int lines = canvas.height(); + const int span = canvas.width(); + + // What stays put for the whole clip: which lines are sorted, and where + // their runs are cut. + Rng steady = at.steady(); + std::vector plan(static_cast(lines)); + for (auto& line : plan) { + line.sorted = chance(steady.next(), settings.lineChance); + line.seed = steady.next(); + } + + // Where this frame is in time: the middle of the window eases from + // `from` to `to` and back once a cycle. + const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0; + const double ease = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5; + const double middle = settings.from + (settings.to - settings.from) * ease; + SortRule rule; + rule.low = static_cast(std::lround((middle - settings.width * 0.5) * 255.0)); + rule.high = static_cast(std::lround((middle + settings.width * 0.5) * 255.0)); + rule.key = settings.key; + rule.reverse = settings.reverse; + rule.maxRun = settings.maxRun; + + parallelFor(static_cast(lines), [&](std::size_t begin, std::size_t end) { + std::vector>> run; + for (std::size_t line = begin; line < end; ++line) { + if (!plan[line].sorted) continue; + Rng runRng(plan[line].seed); + sortLine(canvas.row(static_cast(line)), span, rule, runRng, run); + } + }); + + return settings.vertical ? transposed(canvas) : canvas; +} + +} // namespace effector diff --git a/src/effects/sweep.hpp b/src/effects/sweep.hpp new file mode 100644 index 0000000..7fb2b8a --- /dev/null +++ b/src/effects/sweep.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "effects/video_effect.hpp" + +namespace effector { + +// The pixel sort in motion: only pixels inside a window of brightness are +// sorted, and the window sweeps from the dark end to the bright end and back, +// so the sorted streaks spread over one part of the picture after another, +// melt away and return. +// +// Which lines are sorted, and how their runs are cut, is drawn from +// at.steady() and stays put; the window alone moves, there and back once a +// cycle, so the clip loops without a jump. +class SweepEffect : public VideoEffect { +public: + std::string name() const override { return "sweep"; } + std::string title() const override { return "Sort sweep"; } + std::string description() const override { + return "Sorts runs of pixels as Pixel sort does, only within a window of brightness that " + "sweeps from dark to bright and back."; + } + const std::vector& params() const override; + +protected: + Image animate(Image input, const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 9955f6d..9382b23 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -2108,6 +2108,8 @@ int main() { const std::vector moving = { {"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}}, {"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}}, + {"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {}, + {{"sweep_line_chance", 0.0}}}, }; for (const auto& [name, lively, smooth, off] : moving) { const auto effect = effectByName(name);