Add Echo tunnel, a video effect that flies through Feedback's nested echoes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
|
||||
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.
|
||||
- Echo tunnel, a video effect: the picture nested inside itself as Feedback
|
||||
nests it, flying into the echoes (or backing out of them) without end, a
|
||||
new echo fading in at the far end as the nearest melts into the picture.
|
||||
On the command line it is `-e tunnel` with `--tunnel-speed`,
|
||||
`--tunnel-scale`, `--tunnel-direction` and the rest.
|
||||
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
||||
how the saved video is encoded. It is kept in the project, and on the
|
||||
command line it is `--video-quality small|standard|high`. Standard writes
|
||||
|
||||
@@ -58,6 +58,7 @@ add_library(effector_core STATIC
|
||||
src/effects/tiles.cpp
|
||||
src/effects/tileswap.cpp
|
||||
src/effects/tracking.cpp
|
||||
src/effects/tunnel.cpp
|
||||
src/effects/video_effect.cpp
|
||||
src/effects/warp.cpp
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/tileswap.hpp"
|
||||
#include "effects/tracking.hpp"
|
||||
#include "effects/tunnel.hpp"
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -41,6 +42,7 @@ const std::vector<EffectPtr>& effects() {
|
||||
// Video effects, offered only in a video project.
|
||||
std::make_shared<TrackingEffect>(),
|
||||
std::make_shared<TileSwapEffect>(),
|
||||
std::make_shared<TunnelEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "effects/tunnel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TunnelEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("tunnel_count", "Echoes", 8, 2, 32,
|
||||
"How many echoes are nested inside the picture at once."),
|
||||
ParamSpec::makeDouble("tunnel_scale", "Scale", 0.8, 0.3, 0.95,
|
||||
"Size of each echo against the one around it.", 0.01),
|
||||
ParamSpec::makeDouble("tunnel_rotate", "Rotation", 6.0, -180.0, 180.0,
|
||||
"Turn in degrees of each echo against the one around it.", 0.5, 1),
|
||||
ParamSpec::makeInt("tunnel_drift", "Drift", 0, 0, 4096,
|
||||
"How far in pixels each echo sits off the centre of the one around "
|
||||
"it, all in one direction of the seed's own."),
|
||||
ParamSpec::makeDouble("tunnel_fade", "Opacity", 0.8, 0.0, 1.0,
|
||||
"How strongly each echo covers what lies beneath it."),
|
||||
ParamSpec::makeDouble("tunnel_speed", "Speed", 0.5, 0.0, 10.0,
|
||||
"Echoes passed per second, rounded so that the clip loops without "
|
||||
"a jump; 0 holds still.",
|
||||
0.1, 1),
|
||||
ParamSpec::makeChoice("tunnel_direction", "Direction", "in", {"in", "out"},
|
||||
"in flies into the tunnel, the echoes growing towards you; out "
|
||||
"backs away from it, the echoes shrinking into the distance."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int count;
|
||||
double scale;
|
||||
double rotate;
|
||||
int drift;
|
||||
double fade;
|
||||
double speed;
|
||||
bool out;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: count(static_cast<int>(std::clamp(paramInt(values, "tunnel_count", 8), 2L, 32L))),
|
||||
scale(std::clamp(paramDouble(values, "tunnel_scale", 0.8), 0.3, 0.95)),
|
||||
rotate(std::clamp(paramDouble(values, "tunnel_rotate", 6.0), -180.0, 180.0)),
|
||||
drift(static_cast<int>(std::max(0L, paramInt(values, "tunnel_drift", 0)))),
|
||||
fade(std::clamp(paramDouble(values, "tunnel_fade", 0.8), 0.0, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "tunnel_speed", 0.5))),
|
||||
out(paramChoice(values, "tunnel_direction", "in") == "out") {}
|
||||
};
|
||||
|
||||
// One echo as it lies in this frame: how a pixel of the canvas finds the
|
||||
// pixel of the input it shows, and how strongly it covers what is beneath.
|
||||
struct Echo {
|
||||
double sine;
|
||||
double cosine;
|
||||
double shrink; // 1 / size
|
||||
double dx;
|
||||
double dy;
|
||||
int mix; // out of 256
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TunnelEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
|
||||
// What stays put for the whole clip: the direction the echoes drift in.
|
||||
Rng steady = at.steady();
|
||||
double driftSine = 0.0;
|
||||
double driftCosine = 1.0;
|
||||
sinCosDegrees(unitDouble(steady.next()) * 360.0, driftSine, driftCosine);
|
||||
|
||||
// Where this frame is in time: how far each echo has moved on from its
|
||||
// place at the start of the step. Echo k sits at depth k + 1 - t flying
|
||||
// in, k + t backing out; at depth 0 it is the picture itself, so it can
|
||||
// melt into it, and a new one fades in at the far end as the old leaves.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<Echo> echoes;
|
||||
echoes.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (int k = 0; k < settings.count; ++k) {
|
||||
const double depth = settings.out ? k + t : k + 1.0 - t;
|
||||
const double weight = settings.fade * std::clamp(depth, 0.0, 1.0) *
|
||||
std::clamp(settings.count - depth, 0.0, 1.0);
|
||||
const int mix = static_cast<int>(weight * 256.0 + 0.5);
|
||||
if (mix <= 0) continue;
|
||||
Echo echo{};
|
||||
sinCosDegrees(settings.rotate * depth, echo.sine, echo.cosine);
|
||||
echo.shrink = 1.0 / std::pow(settings.scale, depth);
|
||||
echo.dx = settings.drift * depth * driftCosine;
|
||||
echo.dy = settings.drift * depth * driftSine;
|
||||
echo.mix = mix;
|
||||
echoes.push_back(echo);
|
||||
}
|
||||
if (echoes.empty()) return input;
|
||||
|
||||
// The echoes are laid largest first, so the deeper ones nest inside.
|
||||
const double cx = (width - 1) * 0.5;
|
||||
const double cy = (height - 1) * 0.5;
|
||||
Image frame(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
const std::uint8_t* base = input.row(static_cast<int>(row));
|
||||
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const std::size_t px = static_cast<std::size_t>(col) * 3;
|
||||
int colour[3] = {base[px], base[px + 1], base[px + 2]};
|
||||
for (const Echo& echo : echoes) {
|
||||
const double rx = col - cx - echo.dx;
|
||||
const double ry = static_cast<double>(row) - cy - echo.dy;
|
||||
const double sx = cx + (rx * echo.cosine + ry * echo.sine) * echo.shrink;
|
||||
const double sy = cy + (-rx * echo.sine + ry * echo.cosine) * echo.shrink;
|
||||
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) continue;
|
||||
std::uint8_t echoed[3];
|
||||
sampleBilinear(input, sx, sy, echoed);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
colour[c] += (echoed[c] - colour[c]) * echo.mix / 256;
|
||||
}
|
||||
}
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[px + static_cast<std::size_t>(c)] = static_cast<std::uint8_t>(colour[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The feedback effect in motion: the picture nested inside itself, each copy
|
||||
// smaller, turned and drifted further than the one around it, and the whole
|
||||
// stack flying inwards (or outwards) without end.
|
||||
//
|
||||
// A copy sits at a depth, and its size, turn and drift follow from that depth
|
||||
// alone. Every frame moves each copy on by part of one step, so after a whole
|
||||
// cycle each copy lies exactly where its neighbour started: a new one fades
|
||||
// in at the far end while the nearest fades out into the picture itself, and
|
||||
// the clip loops without a jump.
|
||||
class TunnelEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tunnel"; }
|
||||
std::string title() const override { return "Echo tunnel"; }
|
||||
std::string description() const override {
|
||||
return "Nests the picture inside itself, as Feedback does, and flies through the echoes "
|
||||
"without end.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -2095,6 +2095,81 @@ int main() {
|
||||
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// The video transforms made from image effects. Each gets a setting
|
||||
// that moves plenty, one without its per-frame noise (which, drawn
|
||||
// anew on every frame, is the one thing that does not loop), and one
|
||||
// that switches it off.
|
||||
struct Moving {
|
||||
std::string name;
|
||||
effector::ParamValues lively;
|
||||
effector::ParamValues smooth;
|
||||
effector::ParamValues off;
|
||||
};
|
||||
const std::vector<Moving> moving = {
|
||||
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
|
||||
};
|
||||
for (const auto& [name, lively, smooth, off] : moving) {
|
||||
const auto effect = effectByName(name);
|
||||
auto with = [&](const effector::ParamValues& changes) {
|
||||
ChainStage built = stage(name);
|
||||
for (const auto& [key, value] : changes) built.values[key] = value;
|
||||
return built;
|
||||
};
|
||||
auto frameOf = [&](const ChainStage& at, int index) {
|
||||
effector::Rng rng(6);
|
||||
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
|
||||
};
|
||||
const ChainStage wildStage = with(lively);
|
||||
check(effect->video() && !effect->needsSources(),
|
||||
name + " is a video effect that needs no sources");
|
||||
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
effector::Rng withIt(3);
|
||||
effector::Rng withoutIt(3);
|
||||
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutIt)),
|
||||
"a still skips " + name + " as though it were not there");
|
||||
|
||||
effector::Rng stateA(9);
|
||||
effector::Rng stateB(9);
|
||||
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
|
||||
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
|
||||
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
|
||||
|
||||
bool changes = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
|
||||
}
|
||||
check(changes, name + " moves over the clip");
|
||||
const ChainStage smoothStage = with(smooth);
|
||||
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
|
||||
name + " loops: the frame after the last is the first again");
|
||||
check(sameBytes(frameOf(with(off), 5), input[0]),
|
||||
name + " switched off leaves its input alone");
|
||||
effector::Rng sliverA(2);
|
||||
effector::Rng sliverB(2);
|
||||
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
|
||||
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
|
||||
name + " copes with a sliver of a canvas");
|
||||
|
||||
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool exactFrames = clip.size() == 8;
|
||||
for (const int index : {0, 5, 7}) {
|
||||
effector::Rng rng(21);
|
||||
exactFrames = exactFrames &&
|
||||
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
|
||||
FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(exactFrames, "a " + name + " 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)};
|
||||
|
||||
Reference in New Issue
Block a user