Add Focus pull, a video effect that pulls Blur in and out of focus

Blur's box blur moves to effects/box_blur.hpp so both share it; Blur renders
exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:45:46 +02:00
co-authored by Claude Opus 5.5
parent 4b407281e2
commit 53dca6b4b8
8 changed files with 228 additions and 64 deletions
+5
View File
@@ -41,6 +41,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
through its image, or sliding sideways, while the strips stay put. On the
command line it is `-e striproll` with `--roll-speed`, `--roll-axis`,
`--roll-direction` and the rest.
- Focus pull, a video effect: a lens being focused, either the whole
picture blurring as Blur blurs it and coming back sharp, or a band of
sharpness moving down the picture and back up. On the command line it is
`-e focuspull` with `--focus-mode`, `--focus-radius`, `--focus-band` and
`--focus-speed`.
- 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
+1
View File
@@ -49,6 +49,7 @@ add_library(effector_core STATIC
src/effects/datamosh.cpp
src/effects/feedback.cpp
src/effects/filmgate.cpp
src/effects/focuspull.cpp
src/effects/flow.cpp
src/effects/grain.cpp
src/effects/monochrome.cpp
+2
View File
@@ -7,6 +7,7 @@
#include "effects/datamosh.hpp"
#include "effects/feedback.hpp"
#include "effects/filmgate.hpp"
#include "effects/focuspull.hpp"
#include "effects/flow.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
@@ -53,6 +54,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<FilmGateEffect>(),
std::make_shared<DatamoshEffect>(),
std::make_shared<StripRollEffect>(),
std::make_shared<FocusPullEffect>(),
};
return registry;
}
+2 -64
View File
@@ -1,10 +1,9 @@
#include "effects/blur.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/box_blur.hpp"
#include "effects/source_fill.hpp"
namespace effector {
@@ -23,57 +22,6 @@ const std::vector<ParamSpec>& BlurEffect::params() const {
return specs;
}
namespace {
// One separable box blur pass. `stride` is the distance in bytes between two
// neighbouring samples along the blurred axis, `count` how many of them there
// are; the window is clamped at both ends.
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
const int window = 2 * radius + 1;
int sum[3] = {0, 0, 0};
for (int c = 0; c < 3; ++c) {
sum[c] = in[c] * (radius + 1);
for (int i = 1; i <= radius; ++i) {
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
}
}
for (int i = 0; i < count; ++i) {
for (int c = 0; c < 3; ++c) {
out[static_cast<std::size_t>(i) * stride + c] =
static_cast<std::uint8_t>(sum[c] / window);
}
const int add = std::min(i + radius + 1, count - 1);
const int drop = std::max(i - radius, 0);
for (int c = 0; c < 3; ++c) {
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
in[static_cast<std::size_t>(drop) * stride + c];
}
}
}
void blurHorizontal(const Image& src, Image& dst, int radius) {
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
radius);
}
});
}
void blurVertical(const Image& src, Image& dst, int radius) {
const std::size_t stride = src.stride();
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
for (std::size_t x = begin; x < end; ++x) {
const std::size_t offset = x * 3;
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
}
});
}
} // namespace
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -94,17 +42,7 @@ Image BlurEffect::render(const std::vector<Image>& sources, int width, int heigh
fillFromSource(canvas, src, sx, sy);
}
Image scratch(width, height);
for (int pass = 0; pass < passes; ++pass) {
if (direction != "vertical") {
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
std::swap(canvas, scratch);
}
if (direction != "horizontal") {
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
std::swap(canvas, scratch);
}
}
boxBlur(canvas, radius, passes, direction);
return canvas;
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <string>
#include <utility>
#include "core/image.hpp"
#include "core/parallel.hpp"
namespace effector {
// One separable box blur pass. `stride` is the distance in bytes between two
// neighbouring samples along the blurred axis, `count` how many of them there
// are; the window is clamped at both ends.
inline void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride,
int radius) {
const int window = 2 * radius + 1;
int sum[3] = {0, 0, 0};
for (int c = 0; c < 3; ++c) {
sum[c] = in[c] * (radius + 1);
for (int i = 1; i <= radius; ++i) {
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
}
}
for (int i = 0; i < count; ++i) {
for (int c = 0; c < 3; ++c) {
out[static_cast<std::size_t>(i) * stride + c] =
static_cast<std::uint8_t>(sum[c] / window);
}
const int add = std::min(i + radius + 1, count - 1);
const int drop = std::max(i - radius, 0);
for (int c = 0; c < 3; ++c) {
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
in[static_cast<std::size_t>(drop) * stride + c];
}
}
}
inline void blurHorizontal(const Image& src, Image& dst, int radius) {
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
radius);
}
});
}
inline void blurVertical(const Image& src, Image& dst, int radius) {
const std::size_t stride = src.stride();
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
for (std::size_t x = begin; x < end; ++x) {
const std::size_t offset = x * 3;
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
}
});
}
// Blurs `canvas` in place: `passes` box blurs of `radius` along the axes
// `direction` names ("both", "horizontal" or "vertical"). Shared by the blur
// and the focus pull effects.
inline void boxBlur(Image& canvas, int radius, int passes, const std::string& direction) {
const int width = canvas.width();
const int height = canvas.height();
Image scratch(width, height);
for (int pass = 0; pass < passes; ++pass) {
if (direction != "vertical") {
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
std::swap(canvas, scratch);
}
if (direction != "horizontal") {
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
std::swap(canvas, scratch);
}
}
}
} // namespace effector
+108
View File
@@ -0,0 +1,108 @@
#include "effects/focuspull.hpp"
#include <algorithm>
#include <cmath>
#include <numbers>
#include <vector>
#include "core/parallel.hpp"
#include "effects/box_blur.hpp"
namespace effector {
const std::vector<ParamSpec>& FocusPullEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeChoice("focus_mode", "Mode", "breathe", {"breathe", "band"},
"breathe pulls the whole picture out of focus and back; band "
"moves a band of sharpness down the picture and back up."),
ParamSpec::makeInt("focus_radius", "Radius", 12, 0, 256,
"Half width in pixels of the blur at its strongest."),
ParamSpec::makeDouble("focus_band", "Band", 0.3, 0.05, 1.0,
"Height of the sharp band as a share of the picture, with as much "
"again fading into the blur; band mode only."),
ParamSpec::makeDouble("focus_speed", "Speed", 0.25, 0.0, 10.0,
"Pulls there and back per second, rounded so that the clip loops "
"without a jump; 0 holds still.",
0.05, 2),
};
return specs;
}
namespace {
// Two passes of a box blur come close enough to a lens.
constexpr int kPasses = 2;
struct Settings {
bool band;
int radius;
double bandShare;
double speed;
explicit Settings(const ParamValues& values)
: band(paramChoice(values, "focus_mode", "breathe") == "band"),
radius(static_cast<int>(std::max(0L, paramInt(values, "focus_radius", 12)))),
bandShare(std::clamp(paramDouble(values, "focus_band", 0.3), 0.05, 1.0)),
speed(std::max(0.0, paramDouble(values, "focus_speed", 0.25))) {}
};
Image blurred(const Image& input, int radius) {
Image out = input;
if (radius > 0) boxBlur(out, radius, kPasses, "both");
return out;
}
// Lays `over` on `under` in place, row y at `mix(y)` out of 256.
template <typename Mix>
void blendRows(Image& under, const Image& over, Mix mix) {
const std::size_t bytes = static_cast<std::size_t>(under.width()) * 3;
parallelFor(static_cast<std::size_t>(under.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const int weight = mix(static_cast<int>(y));
if (weight <= 0) continue;
std::uint8_t* out = under.row(static_cast<int>(y));
const std::uint8_t* in = over.row(static_cast<int>(y));
for (std::size_t i = 0; i < bytes; ++i) {
out[i] = static_cast<std::uint8_t>(out[i] + (in[i] - out[i]) * weight / 256);
}
}
});
}
} // namespace
Image FocusPullEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
const Settings settings(values);
if (settings.radius <= 0) return input;
const int height = input.height();
// Where this frame is in time: how far the pull has gone, easing out from
// the start and back to it once a cycle.
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
const double pull = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
if (!settings.band) {
// A radius between two whole ones is the two blurs mixed, so the
// focus drifts smoothly rather than in steps.
const double radius = settings.radius * pull;
const int lower = static_cast<int>(radius);
const int mix = static_cast<int>(std::lround((radius - lower) * 256.0));
Image frame = blurred(input, lower);
if (mix > 0) blendRows(frame, blurred(input, lower + 1), [mix](int) { return mix; });
return frame;
}
// The band's middle goes from the top to the bottom and back; the blur
// grows over as much again as the band is high on either side of it.
const double middle = pull * (height - 1);
const double half = settings.bandShare * height * 0.5;
const double fade = std::max(1.0, settings.bandShare * height);
const Image soft = blurred(input, settings.radius);
blendRows(input, soft, [&](int y) {
const double away = std::abs(y - middle) - half;
return static_cast<int>(std::lround(std::clamp(away / fade, 0.0, 1.0) * 256.0));
});
return input;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// The blur effect in motion, as a lens being focused: either the whole
// picture drifts out of focus and back, or a band of sharpness travels down
// the picture and back up with the rest blurred, as a tilted lens shows it.
//
// The pull goes there and back once a cycle, so the clip loops without a
// jump; nothing in it is random.
class FocusPullEffect : public VideoEffect {
public:
std::string name() const override { return "focuspull"; }
std::string title() const override { return "Focus pull"; }
std::string description() const override {
return "Blurs the picture as Blur does, pulling it out of focus and back, or moving a "
"band of sharpness up and down it.";
}
const std::vector<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+3
View File
@@ -2210,6 +2210,9 @@ int main() {
{"gate_scratches", 0L}}},
{"datamosh", {{"mosh_rate", 0.4}, {"mosh_block", 8L}, {"mosh_bleed", true}}, {},
{{"mosh_rate", 0.0}}},
{"focuspull", {{"focus_radius", 5L}}, {}, {{"focus_radius", 0L}}},
{"focuspull", {{"focus_mode", std::string("band")}, {"focus_speed", 1.0}}, {},
{{"focus_mode", std::string("band")}, {"focus_radius", 0L}}},
};
for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name);