From 1b30a079e6e1859db8d8cf8aa2161b683c764f04 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 19:22:21 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++ CLAUDE.md | 10 ++- CMakeLists.txt | 3 +- packaging/arch/PKGBUILD | 2 +- src/core/registry.cpp | 2 + src/effects/tile_plan.hpp | 87 +++++++++++++++++++ src/effects/tiles.cpp | 78 +---------------- src/effects/tileswap.cpp | 158 +++++++++++++++++++++++++++++++++++ src/effects/tileswap.hpp | 31 +++++++ src/effects/video_effect.cpp | 19 +++++ src/effects/video_effect.hpp | 23 +++-- tests/core_tests.cpp | 87 +++++++++++++++++++ 12 files changed, 424 insertions(+), 85 deletions(-) create mode 100644 src/effects/tile_plan.hpp create mode 100644 src/effects/tileswap.cpp create mode 100644 src/effects/tileswap.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 791d5ac..e738730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b5b57b5..48fb871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt index a3a3cb4..04398e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 84c0f10..6f78742 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -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') diff --git a/src/core/registry.cpp b/src/core/registry.cpp index c3c7a85..223b34d 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -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& effects() { std::make_shared(), // Video effects, offered only in a video project. std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/tile_plan.hpp b/src/effects/tile_plan.hpp new file mode 100644 index 0000000..d00db53 --- /dev/null +++ b/src/effects/tile_plan.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +#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 planSpans(Rng& rng, int base, int jitter, int span) { + std::vector spans; + spans.reserve(static_cast(span / base) + 8); + int used = 0; + while (used < span) { + int size = base; + if (jitter > 0) size += static_cast(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(copyWidth) * 3; + const std::size_t total = static_cast(tile.w) * 3; + const std::size_t offset = static_cast(tile.dstX) * 3; + + for (int line = 0; line < copyHeight; ++line) { + const std::uint8_t* in = src.row(tile.sy + line) + static_cast(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& sources, + const std::vector& plan, bool overlapping) { + if (overlapping) { + for (const Tile& tile : plan) { + paintTile(canvas, sources[static_cast(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(plan[i].sourceIndex)], plan[i]); + } + }); +} + +} // namespace effector diff --git a/src/effects/tiles.cpp b/src/effects/tiles.cpp index d315bb1..7323cc2 100644 --- a/src/effects/tiles.cpp +++ b/src/effects/tiles.cpp @@ -1,11 +1,10 @@ #include "effects/tiles.hpp" #include -#include #include -#include "core/parallel.hpp" #include "effects/source_fill.hpp" +#include "effects/tile_plan.hpp" namespace effector { @@ -29,66 +28,6 @@ const std::vector& 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 planSpans(Rng& rng, int base, int jitter, int span) { - std::vector spans; - spans.reserve(static_cast(span / base) + 8); - int used = 0; - while (used < span) { - int size = base; - if (jitter > 0) size += static_cast(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(copyWidth) * 3; - const std::size_t total = static_cast(tile.w) * 3; - const std::size_t offset = static_cast(tile.dstX) * 3; - - for (int line = 0; line < copyHeight; ++line) { - const std::uint8_t* in = src.row(tile.sy + line) + static_cast(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& 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& 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(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(plan[i].sourceIndex)], plan[i]); - } - }); - } + paintTiles(canvas, sources, plan, layout == "scatter"); return canvas; } diff --git a/src/effects/tileswap.cpp b/src/effects/tileswap.cpp new file mode 100644 index 0000000..b391406 --- /dev/null +++ b/src/effects/tileswap.cpp @@ -0,0 +1,158 @@ +#include "effects/tileswap.hpp" + +#include +#include +#include + +#include "effects/source_fill.hpp" +#include "effects/tile_plan.hpp" + +namespace effector { + +const std::vector& TileSwapEffect::params() const { + static const std::vector 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(std::max(1L, paramInt(values, "tileswap_width", 96)))), + tileHeight(static_cast(std::max(1L, paramInt(values, "tileswap_height", 96)))), + jitter(static_cast(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(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& 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 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(steady.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(steady.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + + cells.reserve(static_cast(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(steady.inRange(-settings.jitter, settings.jitter)); + h += static_cast(steady.inRange(-settings.jitter, settings.jitter)); + } + const int x = static_cast(steady.inRange(0, width - 1)); + const int y = static_cast(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 rows = planSpans(steady, settings.tileHeight, settings.jitter, height); + std::vector 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 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(std::floor(at.seconds() * perSecond + cell.phase)); + turn = static_cast(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(cut.inRange(0, std::max(0, src.width() - cell.w))); + const int sy = static_cast(cut.inRange(0, std::max(0, src.height() - cell.h))); + plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast(sourceIndex), sx, sy}); + } + + paintTiles(canvas, sources, plan, scatter); + return canvas; +} + +} // namespace effector diff --git a/src/effects/tileswap.hpp b/src/effects/tileswap.hpp new file mode 100644 index 0000000..2cc2c3b --- /dev/null +++ b/src/effects/tileswap.hpp @@ -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& params() const override; + bool needsSources() const override { return true; } + +protected: + Image compose(const std::vector& sources, int width, int height, + const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/src/effects/video_effect.cpp b/src/effects/video_effect.cpp index ba4a3bb..6c6f94c 100644 --- a/src/effects/video_effect.cpp +++ b/src/effects/video_effect.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "core/video.hpp" #include "effects/source_fill.hpp" @@ -35,6 +36,14 @@ Image VideoEffect::renderFrame(const std::vector& 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& 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&, int, int, const ParamValues&, + const Moment&) const { + throw std::logic_error(name() + " needs sources but writes no compose()"); +} + } // namespace effector diff --git a/src/effects/video_effect.hpp b/src/effects/video_effect.hpp index 7961a94..e7ad713 100644 --- a/src/effects/video_effect.hpp +++ b/src/effects/video_effect.hpp @@ -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& 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& sources, int width, int height, + const ParamValues& values, const Moment& at) const; }; } // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 681cf7e..16dd1ba 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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 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(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 swapChain = {swapStage, stage("grain")}; + std::vector 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(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)};