From 53dca6b4b80d3b725c7b6a168ee475fb20e9178d Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 20:45:46 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 5 ++ CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/blur.cpp | 66 +---------------------- src/effects/box_blur.hpp | 80 ++++++++++++++++++++++++++++ src/effects/focuspull.cpp | 108 ++++++++++++++++++++++++++++++++++++++ src/effects/focuspull.hpp | 27 ++++++++++ tests/core_tests.cpp | 3 ++ 8 files changed, 228 insertions(+), 64 deletions(-) create mode 100644 src/effects/box_blur.hpp create mode 100644 src/effects/focuspull.cpp create mode 100644 src/effects/focuspull.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index e26aa24..bda4a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8afae33..7f3c171 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/core/registry.cpp b/src/core/registry.cpp index 6d02d83..9cb217d 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -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& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp index 99f2f28..ceab3c2 100644 --- a/src/effects/blur.cpp +++ b/src/effects/blur.cpp @@ -1,10 +1,9 @@ #include "effects/blur.hpp" #include -#include #include -#include "core/parallel.hpp" +#include "effects/box_blur.hpp" #include "effects/source_fill.hpp" namespace effector { @@ -23,57 +22,6 @@ const std::vector& 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::min(i, count - 1)) * stride + c]; - } - } - - for (int i = 0; i < count; ++i) { - for (int c = 0; c < 3; ++c) { - out[static_cast(i) * stride + c] = - static_cast(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(add) * stride + c] - - in[static_cast(drop) * stride + c]; - } - } -} - -void blurHorizontal(const Image& src, Image& dst, int radius) { - parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { - for (std::size_t y = begin; y < end; ++y) { - boxPass(src.row(static_cast(y)), dst.row(static_cast(y)), src.width(), 3, - radius); - } - }); -} - -void blurVertical(const Image& src, Image& dst, int radius) { - const std::size_t stride = src.stride(); - parallelFor(static_cast(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& 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& 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; } diff --git a/src/effects/box_blur.hpp b/src/effects/box_blur.hpp new file mode 100644 index 0000000..5ef252f --- /dev/null +++ b/src/effects/box_blur.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include + +#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::min(i, count - 1)) * stride + c]; + } + } + + for (int i = 0; i < count; ++i) { + for (int c = 0; c < 3; ++c) { + out[static_cast(i) * stride + c] = + static_cast(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(add) * stride + c] - + in[static_cast(drop) * stride + c]; + } + } +} + +inline void blurHorizontal(const Image& src, Image& dst, int radius) { + parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + boxPass(src.row(static_cast(y)), dst.row(static_cast(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(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 diff --git a/src/effects/focuspull.cpp b/src/effects/focuspull.cpp new file mode 100644 index 0000000..f6579b6 --- /dev/null +++ b/src/effects/focuspull.cpp @@ -0,0 +1,108 @@ +#include "effects/focuspull.hpp" + +#include +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/box_blur.hpp" + +namespace effector { + +const std::vector& FocusPullEffect::params() const { + static const std::vector 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(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 +void blendRows(Image& under, const Image& over, Mix mix) { + const std::size_t bytes = static_cast(under.width()) * 3; + parallelFor(static_cast(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(y)); + if (weight <= 0) continue; + std::uint8_t* out = under.row(static_cast(y)); + const std::uint8_t* in = over.row(static_cast(y)); + for (std::size_t i = 0; i < bytes; ++i) { + out[i] = static_cast(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(radius); + const int mix = static_cast(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(std::lround(std::clamp(away / fade, 0.0, 1.0) * 256.0)); + }); + return input; +} + +} // namespace effector diff --git a/src/effects/focuspull.hpp b/src/effects/focuspull.hpp new file mode 100644 index 0000000..0f26bfe --- /dev/null +++ b/src/effects/focuspull.hpp @@ -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& params() const override; + +protected: + Image animate(Image input, const ParamValues& values, const Moment& at) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 04853e6..0183e51 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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);