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)};