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
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/image.hpp"
#include "core/parallel.hpp"
#include "core/rng.hpp"
namespace effector {
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
// and laid down at (dstX, dstY). Shared by the tiles and the tile swap effects.
struct Tile {
int dstX;
int dstY;
int w;
int h;
int sourceIndex;
int sx;
int sy;
};
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
// the last one clipped so that the pieces cover the span exactly.
inline std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
std::vector<int> spans;
spans.reserve(static_cast<std::size_t>(span / base) + 8);
int used = 0;
while (used < span) {
int size = base;
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
size = std::max(1, std::min(size, span - used));
spans.push_back(size);
used += size;
}
return spans;
}
inline void paintTile(Image& canvas, const Image& src, const Tile& tile) {
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
for (int line = 0; line < copyHeight; ++line) {
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
std::memcpy(out, in, copyBytes);
// Repeat the cut sideways when the source is narrower than the tile,
// doubling the filled span on each pass.
std::size_t filled = copyBytes;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
// Same again downwards, out of the lines already written.
for (int line = copyHeight; line < tile.h; ++line) {
std::memcpy(canvas.row(tile.dstY + line) + offset,
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
}
}
// Paints the whole plan. Overlapping tiles (the scattered layout) are painted
// in plan order on one thread: letting threads race for the same pixels would
// make the output depend on their scheduling.
inline void paintTiles(Image& canvas, const std::vector<Image>& sources,
const std::vector<Tile>& plan, bool overlapping) {
if (overlapping) {
for (const Tile& tile : plan) {
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
}
return;
}
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
}
});
}
} // namespace effector