Add Sort sweep, a video effect that sweeps Pixel sort's window over the brightness

The run sorting moves to effects/sort_runs.hpp so both share it; Pixel sort
renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:37:30 +02:00
co-authored by Claude Opus 5.5
parent 0377cd4abb
commit 37c02a8a7b
8 changed files with 277 additions and 70 deletions
+2
View File
@@ -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<EffectPtr>& effects() {
std::make_shared<TileSwapEffect>(),
std::make_shared<TunnelEffect>(),
std::make_shared<FlowEffect>(),
std::make_shared<SweepEffect>(),
};
return registry;
}
+8 -70
View File
@@ -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<ParamSpec>& 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<std::size_t>(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<int>(x));
for (int y = 0; y < src.height(); ++y) {
std::memcpy(row + static_cast<std::size_t>(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<Image>& 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<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> 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<int>(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<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
}
run.clear();
};
int start = 0;
int limit = maxRun > 0 ? static_cast<int>(runRng.inRange(1, maxRun)) : span;
for (int x = 0; x < span; ++x) {
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
const int brightness = luma(px);
if (brightness < low || brightness > high) {
flush(start);
start = x + 1;
continue;
}
if (static_cast<int>(run.size()) >= limit) {
flush(start);
start = x;
if (maxRun > 0) limit = static_cast<int>(runRng.inRange(1, maxRun));
}
run.push_back({keyOf(px), {px[0], px[1], px[2]}});
}
flush(start);
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
}
});
+115
View File
@@ -0,0 +1,115 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
#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<std::size_t>(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<int>(x));
for (int y = 0; y < src.height(); ++y) {
std::memcpy(row + static_cast<std::size_t>(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<std::pair<int, std::array<std::uint8_t, 3>>>& 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<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
}
run.clear();
};
int start = 0;
int limit = rule.maxRun > 0 ? static_cast<int>(runRng.inRange(1, rule.maxRun)) : span;
for (int x = 0; x < span; ++x) {
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
const int brightness = sortLuma(px);
if (brightness < rule.low || brightness > rule.high) {
flush(start);
start = x + 1;
continue;
}
if (static_cast<int>(run.size()) >= limit) {
flush(start);
start = x;
if (rule.maxRun > 0) limit = static_cast<int>(runRng.inRange(1, rule.maxRun));
}
run.push_back({sortKeyOf(rule.key, px), {px[0], px[1], px[2]}});
}
flush(start);
}
} // namespace effector
+115
View File
@@ -0,0 +1,115 @@
#include "effects/sweep.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <numbers>
#include <utility>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/sort_runs.hpp"
namespace effector {
const std::vector<ParamSpec>& SweepEffect::params() const {
static const std::vector<ParamSpec> 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<int>(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<LinePlan> plan(static_cast<std::size_t>(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<int>(std::lround((middle - settings.width * 0.5) * 255.0));
rule.high = static_cast<int>(std::lround((middle + settings.width * 0.5) * 255.0));
rule.key = settings.key;
rule.reverse = settings.reverse;
rule.maxRun = settings.maxRun;
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> 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<int>(line)), span, rule, runRng, run);
}
});
return settings.vertical ? transposed(canvas) : canvas;
}
} // namespace effector
+29
View File
@@ -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<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector