Add Tile swap, a video effect built from the source images

Tile swap lays tiles cut from the photographs over the canvas as Tiles
does, in a grid, in bricks or scattered, and swaps each one for another
cut at a moment of its own, so the mosaic keeps changing piece by piece.
The layout stays put for the whole clip; what a tile shows depends only
on the tile and how many times it has swapped, counted round the clip, so
it holds still between swaps and the clip loops without a jump. On the
command line it is -e tileswap with --tileswap-rate and the rest.

VideoEffect learns a second kind: an effect that answers needsSources()
writes compose() instead of animate(), and the base then takes no crop,
only the one seed, so it sees the photographs when it leads the chain and
the stage before it otherwise. Transforms render exactly as before. The
tile cutting and painting move out of tiles.cpp into tile_plan.hpp for
both effects to share; Tiles renders byte for byte as before. Version
0.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 19:22:26 +02:00
co-authored by Claude Opus 5.5
parent cdaadffc89
commit 1b30a079e6
12 changed files with 424 additions and 85 deletions
+158
View File
@@ -0,0 +1,158 @@
#include "effects/tileswap.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "effects/source_fill.hpp"
#include "effects/tile_plan.hpp"
namespace effector {
const std::vector<ParamSpec>& TileSwapEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
"grid lines the tiles up in rows and columns; brick packs every row "
"on its own so the seams do not meet; scatter drops loose tiles over "
"a filled background, overlapping."),
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
"Width in pixels of each cut-out tile."),
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
"Height in pixels of each cut-out tile."),
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
"Random +/- variation added to both tile sizes (0 = uniform)."),
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
"How many tiles are dropped on the canvas; scattered layout only."),
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
"Take tiles from the images in order instead of picking randomly."),
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 30.0,
"Swaps per second of each tile, rounded so that the clip loops "
"without a jump; 0 for none.",
0.1, 1),
};
return specs;
}
namespace {
struct Settings {
std::string layout;
int tileWidth;
int tileHeight;
int jitter;
long count;
bool keepOrder;
double rate;
explicit Settings(const ParamValues& values)
: layout(paramChoice(values, "tileswap_layout", "grid")),
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
keepOrder(paramBool(values, "tileswap_keep_order", false)),
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
};
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
// Where a tile sits for the whole clip, when in its swaps it turns over, and
// the seed its cuts come from.
struct Cell {
int x;
int y;
int w;
int h;
double phase;
std::uint64_t seed;
};
} // namespace
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
const ParamValues& values, const Moment& at) const {
const Settings settings(values);
const bool scatter = settings.layout == "scatter";
Image canvas(width, height);
// What stays put for the whole clip: the cells, and for loose tiles the
// backdrop they lie on.
Rng steady = at.steady();
std::vector<Cell> cells;
auto addCell = [&](int x, int y, int w, int h) {
cells.push_back(Cell{x, y, w, h, 0.0, 0});
};
if (scatter) {
const Image& src = sources[steady.below(sources.size() - 1)];
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
cells.reserve(static_cast<std::size_t>(settings.count));
for (long i = 0; i < settings.count; ++i) {
int w = settings.tileWidth;
int h = settings.tileHeight;
if (settings.jitter > 0) {
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
}
const int x = static_cast<int>(steady.inRange(0, width - 1));
const int y = static_cast<int>(steady.inRange(0, height - 1));
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
}
} else {
const bool brick = settings.layout == "brick";
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
std::vector<int> columns;
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
int y = 0;
for (const int rowHeight : rows) {
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
int x = 0;
for (const int cellWidth : columns) {
addCell(x, y, cellWidth, rowHeight);
x += cellWidth;
}
y += rowHeight;
}
}
for (Cell& cell : cells) {
cell.phase = unit(steady);
cell.seed = steady.next();
}
// Where this frame is in time: each tile has swapped a whole number of
// times, counted round the clip so that the frame after the last shows
// what the first does.
long swaps = 0;
double perSecond = 0.0;
if (settings.rate > 0.0) {
perSecond = at.loopRate(settings.rate);
swaps = std::max(1L, std::lround(perSecond * at.length()));
}
// What a tile shows depends only on the tile and its swap, so it holds
// still between swaps and any frame can be drawn on its own.
std::vector<Tile> plan;
plan.reserve(cells.size());
for (std::size_t i = 0; i < cells.size(); ++i) {
const Cell& cell = cells[i];
std::uint64_t turn = 0;
if (swaps > 0) {
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + cell.phase));
turn = static_cast<std::uint64_t>(passed % swaps);
}
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
const std::size_t sourceIndex =
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
const Image& src = sources[sourceIndex];
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
}
paintTiles(canvas, sources, plan, scatter);
return canvas;
}
} // namespace effector