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
+9
View File
@@ -4,6 +4,15 @@ Every release of Effector, newest first. The version lives in one place, the
`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
commit that made it. The interface shows this file under Changelog in its menu.
## [0.8.0] - 2026-09-26
### Added
- Tile swap, a video effect: tiles cut from the photographs are laid over the
canvas as Tiles lays them, and each one is swapped for another cut at a
moment of its own, so the mosaic keeps changing piece by piece. On the
command line it is `-e tileswap` with `--tileswap-rate`,
`--tileswap-layout` and the rest.
## [0.7.0] - 2026-09-26
### Added
+9 -1
View File
@@ -195,7 +195,7 @@ the Add effect menu, with the video section insensitive in a still, a
still skips it. The CLI groups `--help` the same way and warns when it skips one.
A video effect subclasses `VideoEffect` (`src/effects/video_effect.hpp`), not
`Effect`, and writes only `animate(input, values, const Moment& at)`. The base
`Effect`, and a transform writes only `animate(input, values, const Moment& at)`. The base
keeps the rules. It takes the input crop and exactly one value from the shared
rng, the same on every frame, and hands out randomness through the `Moment`:
`at.steady()` gives the same draws every frame (phases, directions),
@@ -204,6 +204,14 @@ rng, the same on every frame, and hands out randomness through the `Moment`:
the clip loops without a jump. `TrackingEffect` (`src/effects/tracking.cpp`,
"VHS tracking") is the reference: copy its layout (params, a `Settings` struct,
steady draws, time, fresh draws, one parallel pixel pass) for the next one.
A video effect that builds its frames out of the sources rather than
transforming a crop answers `needsSources()` and writes `compose(sources,
width, height, values, at)` instead of `animate`: the base then takes no crop,
only the seed, and like an image effect it sees the photographs when it leads
the chain and the stage before it otherwise. `TileSwapEffect`
(`src/effects/tileswap.cpp`, "Tile swap") is the reference for that kind; it
shares `Tile`/`planSpans`/`paintTiles` with the tiles effect through
`src/effects/tile_plan.hpp`.
Registering it is the same one line in `registry.cpp`, after the comment that
starts the video effects. There is no per-param video flag: motion is a video
effect of its own, never extra params on an image effect.
+2 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.7.0 LANGUAGES CXX)
project(effector VERSION 0.8.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -56,6 +56,7 @@ add_library(effector_core STATIC
src/effects/slitshift.cpp
src/effects/text.cpp
src/effects/tiles.cpp
src/effects/tileswap.cpp
src/effects/tracking.cpp
src/effects/video_effect.cpp
src/effects/warp.cpp
+1 -1
View File
@@ -2,7 +2,7 @@
# so pkgver must follow the project() line of CMakeLists.txt (the version core
# test checks it). From this folder: makepkg -si
pkgname=effector
pkgver=0.7.0
pkgver=0.8.0
pkgrel=1
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
arch=('x86_64' 'aarch64')
+2
View File
@@ -14,6 +14,7 @@
#include "effects/slitshift.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
#include "effects/tileswap.hpp"
#include "effects/tracking.hpp"
#include "effects/warp.hpp"
@@ -39,6 +40,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<FeedbackEffect>(),
// Video effects, offered only in a video project.
std::make_shared<TrackingEffect>(),
std::make_shared<TileSwapEffect>(),
};
return registry;
}
+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
+2 -76
View File
@@ -1,11 +1,10 @@
#include "effects/tiles.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
#include "effects/tile_plan.hpp"
namespace effector {
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
return specs;
}
namespace {
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
// and laid down at (dstX, dstY).
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.
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;
}
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);
}
}
} // namespace
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (sources.empty()) throw EffectError("No source images.");
@@ -159,20 +98,7 @@ Image TilesEffect::render(const std::vector<Image>& sources, int width, int heig
}
}
if (layout == "scatter") {
// Scattered tiles overlap, so they are painted in plan order on one
// thread: letting threads race for the same pixels would make the
// output depend on their scheduling.
for (const Tile& tile : plan) {
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
}
} else {
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]);
}
});
}
paintTiles(canvas, sources, plan, layout == "scatter");
return canvas;
}
+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
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// The tiles effect in motion: the canvas is laid with tiles cut from the
// source images, and each tile is swapped for another cut, at a moment of its
// own, so the mosaic keeps changing piece by piece.
//
// The video effect built from the sources rather than transforming its input:
// it answers needsSources() and writes compose(). The layout is drawn from
// at.steady() and stays put; what a tile shows depends only on the tile and
// how many times it has swapped, so it holds still between its swaps.
class TileSwapEffect : public VideoEffect {
public:
std::string name() const override { return "tileswap"; }
std::string title() const override { return "Tile swap"; }
std::string description() const override {
return "Lays tiles cut from the source images over the canvas, as Tiles does, and swaps "
"each one for another cut at a moment of its own.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return true; }
protected:
Image compose(const std::vector<Image>& sources, int width, int height,
const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+19
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include "core/video.hpp"
#include "effects/source_fill.hpp"
@@ -35,6 +36,14 @@ Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const FrameTime at = frame.video() ? frame : FrameTime{0, kDefaultFps, kDefaultFps};
// An effect built from the sources takes no crop, only the one seed
// everything it draws comes from -- the same value on every frame.
if (needsSources()) {
if (sources.empty()) throw EffectError("No source images.");
const std::uint64_t seed = rng.next();
return compose(sources, width, height, values, Moment(at, seed));
}
// Both of these take the same values from the shared rng on every frame:
// the crop a leading stage starts from, and the one seed everything the
// effect draws comes from.
@@ -44,4 +53,14 @@ Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int
return animate(std::move(input), values, Moment(at, seed));
}
// A video effect writes one of these two; the other is never called.
Image VideoEffect::animate(Image, const ParamValues&, const Moment&) const {
throw std::logic_error(name() + " is a transform but writes no animate()");
}
Image VideoEffect::compose(const std::vector<Image>&, int, int, const ParamValues&,
const Moment&) const {
throw std::logic_error(name() + " needs sources but writes no compose()");
}
} // namespace effector
+17 -6
View File
@@ -52,17 +52,22 @@ private:
// hands the effect generators of its own through the Moment. The stages
// after it therefore see the rng in one state throughout and hold still
// while it moves.
// - It is a transform: it needs no source images, and leading a chain it
// starts from a crop of one, as the other transforms do.
// - It is one of two kinds. A transform (the default) writes animate(): it
// needs no source images, and leading a chain it starts from a crop of
// one, as the other transforms do. One that builds its frames out of the
// sources answers needsSources() and writes compose() instead: it is
// handed the loaded photographs when it leads a chain and the stage before
// it otherwise, as the image effects are, and takes no crop.
// - It never runs in a still project (the chain skips it). Asked for a still
// directly, it gives the first frame of a one-second clip.
//
// A new video effect subclasses this, declares its params like any effect,
// and writes animate(); see TrackingEffect for one.
// and writes animate() or compose(); see TrackingEffect for a transform and
// TileSwapEffect for one built from the sources.
class VideoEffect : public Effect {
public:
bool video() const final { return true; }
bool needsSources() const final { return false; }
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const final;
@@ -72,8 +77,14 @@ public:
protected:
// One frame of the clip: `input` is the picture this stage is handed,
// already at the canvas size, and what comes back is the same size.
// Every random value comes from `at`, never from anywhere else.
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const = 0;
// Every random value comes from `at`, never from anywhere else. A
// transform writes this.
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const;
// One frame built out of `sources` (never empty) at the canvas size, for
// an effect that answers needsSources(); the same rule on randomness.
virtual Image compose(const std::vector<Image>& sources, int width, int height,
const ParamValues& values, const Moment& at) const;
};
} // namespace effector
+87
View File
@@ -1974,6 +1974,93 @@ int main() {
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
// Tile swap builds its frames out of the sources rather than
// transforming a crop of one.
{
const auto swap = effectByName("tileswap");
check(swap->video() && swap->needsSources(),
"tile swap is a video effect built from the sources");
ChainStage swapStage = stage("tileswap", {{"tileswap_width", 16L},
{"tileswap_height", 12L},
{"tileswap_rate", 2.0}});
ChainStage scanStage = stage("scanlines");
check(chainNeedsSources({swapStage}, true), "and a clip led by it needs sources");
effector::Rng withSwap(3);
effector::Rng withoutSwap(3);
check(sameBytes(renderChain({swapStage, scanStage}, sources, 80, 48, withSwap),
renderChain({scanStage}, sources, 80, 48, withoutSwap)),
"a still skips tile swap as though it were not there");
bool refused = false;
try {
effector::Rng rng(1);
renderChain({swapStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
} catch (const EffectError&) {
refused = true;
}
check(refused, "tile swap with no sources is refused");
const std::vector<effector::Image> photos = {solid(60, 40, 255, 0, 0),
solid(50, 30, 0, 0, 255)};
auto frameOf = [&](const ChainStage& at, int index, int count = 8) {
effector::Rng rng(11);
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, count, 8});
};
effector::Rng swapA(9);
effector::Rng swapB(9);
swap->renderFrame(photos, 80, 48, swapA, swapStage.values, FrameTime{3, 8, 8});
swap->renderFrame(photos, 80, 48, swapB, swapStage.values, FrameTime{5, 8, 8});
check(swapA.next() == swapB.next(), "tile swap leaves the shared rng in one state");
auto onlyPhotos = [](const effector::Image& image) {
for (int y = 0; y < image.height(); ++y) {
const std::uint8_t* row = image.row(y);
for (int x = 0; x < image.width(); ++x) {
const std::uint8_t* p = row + static_cast<std::size_t>(x) * 3;
const bool red = p[0] == 255 && p[1] == 0 && p[2] == 0;
const bool blue = p[0] == 0 && p[1] == 0 && p[2] == 255;
if (!red && !blue) return false;
}
}
return true;
};
check(onlyPhotos(frameOf(swapStage, 0)) && onlyPhotos(frameOf(swapStage, 5)),
"its tiles are cut from the photographs");
bool swapped = false;
for (int index = 1; index < 8; ++index) {
swapped = swapped || !sameBytes(frameOf(swapStage, 0), frameOf(swapStage, index));
}
check(swapped, "its tiles change over the clip");
check(sameBytes(frameOf(swapStage, 0), frameOf(swapStage, 8)),
"and the frame after the last is the first again");
ChainStage frozenSwap = swapStage;
frozenSwap.values["tileswap_rate"] = 0.0;
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
"at a rate of 0 it holds still");
ChainStage scattered = swapStage;
scattered.values["tileswap_layout"] = std::string("scatter");
ChainStage bricks = swapStage;
bricks.values["tileswap_layout"] = std::string("brick");
bricks.values["tileswap_keep_order"] = true;
check(onlyPhotos(frameOf(scattered, 2)) && onlyPhotos(frameOf(bricks, 6)),
"scattered and brick layouts are cut from the photographs too");
const std::vector<ChainStage> swapChain = {swapStage, stage("grain")};
std::vector<effector::Image> clip;
effector::renderFrames(swapChain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
clip.push_back(std::move(frame));
return true;
});
bool alone = clip.size() == 8;
for (const int index : {0, 4, 7}) {
effector::Rng rng(21);
alone = alone && sameBytes(effector::renderChain(swapChain, sources, 80, 48, rng,
{}, FrameTime{index, 8, 8}),
clip[static_cast<std::size_t>(index)]);
}
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
}
// The project file: a video says so, a still says nothing about time.
effector::Project project;
project.stages = {stage("tracking", wild)};