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:
@@ -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
|
`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.
|
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
|
## [0.7.0] - 2026-09-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
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
|
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
|
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`:
|
rng, the same on every frame, and hands out randomness through the `Moment`:
|
||||||
`at.steady()` gives the same draws every frame (phases, directions),
|
`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`,
|
the clip loops without a jump. `TrackingEffect` (`src/effects/tracking.cpp`,
|
||||||
"VHS tracking") is the reference: copy its layout (params, a `Settings` struct,
|
"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.
|
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
|
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
|
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.
|
effect of its own, never extra params on an image effect.
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
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 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -56,6 +56,7 @@ add_library(effector_core STATIC
|
|||||||
src/effects/slitshift.cpp
|
src/effects/slitshift.cpp
|
||||||
src/effects/text.cpp
|
src/effects/text.cpp
|
||||||
src/effects/tiles.cpp
|
src/effects/tiles.cpp
|
||||||
|
src/effects/tileswap.cpp
|
||||||
src/effects/tracking.cpp
|
src/effects/tracking.cpp
|
||||||
src/effects/video_effect.cpp
|
src/effects/video_effect.cpp
|
||||||
src/effects/warp.cpp
|
src/effects/warp.cpp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
# test checks it). From this folder: makepkg -si
|
# test checks it). From this folder: makepkg -si
|
||||||
pkgname=effector
|
pkgname=effector
|
||||||
pkgver=0.7.0
|
pkgver=0.8.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "effects/slitshift.hpp"
|
#include "effects/slitshift.hpp"
|
||||||
#include "effects/text.hpp"
|
#include "effects/text.hpp"
|
||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
#include "effects/tileswap.hpp"
|
||||||
#include "effects/tracking.hpp"
|
#include "effects/tracking.hpp"
|
||||||
#include "effects/warp.hpp"
|
#include "effects/warp.hpp"
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<FeedbackEffect>(),
|
std::make_shared<FeedbackEffect>(),
|
||||||
// Video effects, offered only in a video project.
|
// Video effects, offered only in a video project.
|
||||||
std::make_shared<TrackingEffect>(),
|
std::make_shared<TrackingEffect>(),
|
||||||
|
std::make_shared<TileSwapEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,11 +1,10 @@
|
|||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/parallel.hpp"
|
|
||||||
#include "effects/source_fill.hpp"
|
#include "effects/source_fill.hpp"
|
||||||
|
#include "effects/tile_plan.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
|
|||||||
return specs;
|
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,
|
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const {
|
const ParamValues& values) const {
|
||||||
if (sources.empty()) throw EffectError("No source images.");
|
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") {
|
paintTiles(canvas, sources, plan, 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]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
#include "core/video.hpp"
|
#include "core/video.hpp"
|
||||||
#include "effects/source_fill.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.");
|
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};
|
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:
|
// 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
|
// the crop a leading stage starts from, and the one seed everything the
|
||||||
// effect draws comes from.
|
// 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));
|
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
|
} // namespace effector
|
||||||
|
|||||||
@@ -52,17 +52,22 @@ private:
|
|||||||
// hands the effect generators of its own through the Moment. The stages
|
// 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
|
// after it therefore see the rng in one state throughout and hold still
|
||||||
// while it moves.
|
// while it moves.
|
||||||
// - It is a transform: it needs no source images, and leading a chain it
|
// - It is one of two kinds. A transform (the default) writes animate(): it
|
||||||
// starts from a crop of one, as the other transforms do.
|
// 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
|
// - 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.
|
// directly, it gives the first frame of a one-second clip.
|
||||||
//
|
//
|
||||||
// A new video effect subclasses this, declares its params like any effect,
|
// 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 {
|
class VideoEffect : public Effect {
|
||||||
public:
|
public:
|
||||||
bool video() const final { return true; }
|
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,
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const final;
|
const ParamValues& values) const final;
|
||||||
@@ -72,8 +77,14 @@ public:
|
|||||||
protected:
|
protected:
|
||||||
// One frame of the clip: `input` is the picture this stage is handed,
|
// 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.
|
// already at the canvas size, and what comes back is the same size.
|
||||||
// Every random value comes from `at`, never from anywhere else.
|
// Every random value comes from `at`, never from anywhere else. A
|
||||||
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const = 0;
|
// 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
|
} // namespace effector
|
||||||
|
|||||||
@@ -1974,6 +1974,93 @@ int main() {
|
|||||||
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
|
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");
|
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.
|
// The project file: a video says so, a still says nothing about time.
|
||||||
effector::Project project;
|
effector::Project project;
|
||||||
project.stages = {stage("tracking", wild)};
|
project.stages = {stage("tracking", wild)};
|
||||||
|
|||||||
Reference in New Issue
Block a user