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
+5
View File
@@ -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 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 heat haze or water. On the command line it is `-e flow` with
`--flow-strength`, `--flow-travel`, `--flow-speed` and the rest. `--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 - 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 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 command line it is `--video-quality small|standard|high`. Standard writes
+1
View File
@@ -55,6 +55,7 @@ add_library(effector_core STATIC
src/effects/scanlines.cpp src/effects/scanlines.cpp
src/effects/shatter.cpp src/effects/shatter.cpp
src/effects/slitshift.cpp src/effects/slitshift.cpp
src/effects/sweep.cpp
src/effects/text.cpp src/effects/text.cpp
src/effects/tiles.cpp src/effects/tiles.cpp
src/effects/tileswap.cpp src/effects/tileswap.cpp
+2
View File
@@ -13,6 +13,7 @@
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/shatter.hpp" #include "effects/shatter.hpp"
#include "effects/slitshift.hpp" #include "effects/slitshift.hpp"
#include "effects/sweep.hpp"
#include "effects/text.hpp" #include "effects/text.hpp"
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include "effects/tileswap.hpp" #include "effects/tileswap.hpp"
@@ -45,6 +46,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<TileSwapEffect>(), std::make_shared<TileSwapEffect>(),
std::make_shared<TunnelEffect>(), std::make_shared<TunnelEffect>(),
std::make_shared<FlowEffect>(), std::make_shared<FlowEffect>(),
std::make_shared<SweepEffect>(),
}; };
return registry; return registry;
} }
+8 -70
View File
@@ -7,6 +7,7 @@
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/sampling.hpp" #include "effects/sampling.hpp"
#include "effects/sort_runs.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -34,39 +35,6 @@ const std::vector<ParamSpec>& PixelSortEffect::params() const {
namespace { 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 { struct LinePlan {
bool sorted = false; bool sorted = false;
std::uint64_t seed = 0; // for the run lengths, when they are limited 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(); if (maxRun > 0) line.seed = rng.next();
} }
auto keyOf = [&](const std::uint8_t* px) { SortRule rule;
if (keyName == "hue") return hue(px); rule.low = low;
if (keyName == "saturation") return saturation(px); rule.high = high;
return luma(px); 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) { 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; std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
for (std::size_t line = begin; line < end; ++line) { for (std::size_t line = begin; line < end; ++line) {
if (!plan[line].sorted) continue; if (!plan[line].sorted) continue;
Rng runRng(plan[line].seed); Rng runRng(plan[line].seed);
std::uint8_t* row = canvas.row(static_cast<int>(line)); sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, 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 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);
} }
}); });
+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
+2
View File
@@ -2108,6 +2108,8 @@ int main() {
const std::vector<Moving> moving = { const std::vector<Moving> moving = {
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}}, {"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}}, {"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) { for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name); const auto effect = effectByName(name);