Add Film gate, a video effect of film running through a projector

Film grain's field moves to effects/grain_field.hpp so both share it; Film
grain renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 20:39:57 +02:00
co-authored by Claude Opus 5.5
parent 37c02a8a7b
commit ce0207c41f
8 changed files with 285 additions and 38 deletions
+5
View File
@@ -26,6 +26,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
and back, so the streaks spread over one part of the picture after and back, so the streaks spread over one part of the picture after
another. On the command line it is `-e sweep` with `--sweep-from`, another. On the command line it is `-e sweep` with `--sweep-from`,
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest. `--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
- Film gate, a video effect: film running through a projector, with grain
drawn anew on every frame as Film grain draws it, the picture swaying in
the gate, flicker, dust and scratches. On the command line it is
`-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`,
`--gate-dust` and `--gate-scratches`.
- A Quality choice under Video on Setup: Small file, Standard or High, for - 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 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 command line it is `--video-quality small|standard|high`. Standard writes
+1
View File
@@ -47,6 +47,7 @@ add_library(effector_core STATIC
src/effects/channelsplit.cpp src/effects/channelsplit.cpp
src/effects/corrupt.cpp src/effects/corrupt.cpp
src/effects/feedback.cpp src/effects/feedback.cpp
src/effects/filmgate.cpp
src/effects/flow.cpp src/effects/flow.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
+2
View File
@@ -5,6 +5,7 @@
#include "effects/channelsplit.hpp" #include "effects/channelsplit.hpp"
#include "effects/corrupt.hpp" #include "effects/corrupt.hpp"
#include "effects/feedback.hpp" #include "effects/feedback.hpp"
#include "effects/filmgate.hpp"
#include "effects/flow.hpp" #include "effects/flow.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
@@ -47,6 +48,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<TunnelEffect>(), std::make_shared<TunnelEffect>(),
std::make_shared<FlowEffect>(), std::make_shared<FlowEffect>(),
std::make_shared<SweepEffect>(), std::make_shared<SweepEffect>(),
std::make_shared<FilmGateEffect>(),
}; };
return registry; return registry;
} }
+168
View File
@@ -0,0 +1,168 @@
#include "effects/filmgate.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <numbers>
#include <vector>
#include "core/parallel.hpp"
#include "effects/grain_field.hpp"
#include "effects/sampling.hpp"
namespace effector {
const std::vector<ParamSpec>& FilmGateEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0,
"How far a grain may push a pixel, as a fraction of full scale; "
"the grain is new on every frame."),
ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32,
"Width in pixels of one grain; larger sizes look coarser."),
ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64,
"Furthest in pixels the picture sways sideways in the gate, and half "
"that up and down."),
ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0,
"How far the brightness may jump from one frame to the next, as a "
"fraction of itself."),
ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500,
"Specks of dust on each frame, every one there for that frame alone."),
ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16,
"Thin bright lines running down the film, wandering a little and "
"now and then missing a frame."),
};
return specs;
}
namespace {
struct Settings {
double grain;
int grainSize;
int weave;
double flicker;
int dust;
int scratches;
explicit Settings(const ParamValues& values)
: grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)),
grainSize(static_cast<int>(std::max(1L, paramInt(values, "gate_grain_size", 2)))),
weave(static_cast<int>(std::max(0L, paramInt(values, "gate_weave", 2)))),
flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)),
dust(static_cast<int>(std::max(0L, paramInt(values, "gate_dust", 3)))),
scratches(static_cast<int>(std::max(0L, paramInt(values, "gate_scratches", 1)))) {}
};
double unit(Rng& rng) { return unitDouble(rng.next()); }
// The swings the sway is made of: roughly how often each comes round a
// second, and how much of the sway it carries.
constexpr std::array<double, 3> kSwingRate = {0.7, 1.9, 4.3};
constexpr std::array<double, 3> kSwingShare = {0.5, 0.3, 0.2};
// Blends a pixel towards `shade` by `mix` out of 256.
void towards(std::uint8_t* px, int shade, int mix) {
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>(px[c] + (shade - px[c]) * mix / 256);
}
}
} // namespace
Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
const Settings settings(values);
const int width = input.width();
const int height = input.height();
constexpr double tau = 2.0 * std::numbers::pi;
// What stays put for the whole clip: where each swing of the sway
// starts, and where each scratch runs down the film.
Rng steady = at.steady();
std::array<double, 3> phaseX{};
std::array<double, 3> phaseY{};
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
phaseX[i] = unit(steady) * tau;
phaseY[i] = unit(steady) * tau;
}
std::vector<double> scratchAt(static_cast<std::size_t>(settings.scratches));
for (double& x : scratchAt) x = unit(steady);
// Where this frame is in time: how far the picture has swayed.
double swayX = 0.0;
double swayY = 0.0;
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
const double turn = at.cycle(kSwingRate[i]) * tau;
swayX += kSwingShare[i] * std::sin(turn + phaseX[i]);
swayY += kSwingShare[i] * std::sin(turn + phaseY[i]);
}
const int dx = static_cast<int>(std::lround(swayX * settings.weave));
const int dy = static_cast<int>(std::lround(swayY * settings.weave * 0.5));
// What changes on every frame, all drawn before a pixel is touched: the
// flicker, the grain, the dust, and which scratches show and where.
Rng fresh = at.fresh();
const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0);
const int gain = static_cast<int>(std::lround((1.0 + flicker) * 256.0));
GrainField grain;
if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false);
struct Speck {
int x;
int y;
int radius;
};
std::vector<Speck> specks(static_cast<std::size_t>(settings.dust));
for (Speck& speck : specks) {
speck.x = static_cast<int>(fresh.inRange(0, width - 1));
speck.y = static_cast<int>(fresh.inRange(0, height - 1));
speck.radius = static_cast<int>(fresh.inRange(0, 2));
}
std::vector<int> scratchX;
for (const double x : scratchAt) {
const int wander = static_cast<int>(fresh.inRange(-2, 2));
if (unit(fresh) < 0.85) scratchX.push_back(static_cast<int>(x * (width - 1)) + wander);
}
// The picture moved in the gate, the edges it leaves held from the last
// row and column, and lit up or down by the flicker.
Image frame(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const int sy = std::clamp(static_cast<int>(y) - dy, 0, height - 1);
const std::uint8_t* in = input.row(sy);
std::uint8_t* out = frame.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - dx, 0, width - 1);
const std::uint8_t* from = in + static_cast<std::size_t>(sx) * 3;
std::uint8_t* to = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
to[c] = static_cast<std::uint8_t>(std::min(255, from[c] * gain >> 8));
}
}
}
});
if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true);
// Dust lies dark on the picture; a scratch through the emulsion lets the
// light through.
for (const Speck& speck : specks) {
for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) {
if (y < 0 || y >= height) continue;
for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) {
if (x < 0 || x >= width) continue;
const int ox = x - speck.x;
const int oy = y - speck.y;
if (ox * ox + oy * oy > speck.radius * speck.radius) continue;
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 16, 220);
}
}
}
for (const int x : scratchX) {
if (x < 0 || x >= width) continue;
for (int y = 0; y < height; ++y) {
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 255, 150);
}
}
return frame;
}
} // namespace effector
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// Film running through a projector: the grain is new on every frame, the
// picture sways a little in the gate and its brightness flickers, and dust
// and scratches pass over it.
//
// The sway is a sum of slow swings, each a whole number of times in the
// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and
// are different on every frame, as they are on film.
class FilmGateEffect : public VideoEffect {
public:
std::string name() const override { return "filmgate"; }
std::string title() const override { return "Film gate"; }
std::string description() const override {
return "Film running through a projector: grain new on every frame, as Film grain draws "
"it, a picture swaying in the gate, flicker, dust and scratches.";
}
const std::vector<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+3 -38
View File
@@ -4,7 +4,7 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "effects/grain_field.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -23,14 +23,6 @@ const std::vector<ParamSpec>& GrainEffect::params() const {
return specs; return specs;
} }
namespace {
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
// roughly how grain behaves on film.
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
} // namespace
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
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.");
@@ -54,35 +46,8 @@ Image GrainEffect::render(const std::vector<Image>& sources, int width, int heig
// The whole grain field is drawn serially from the rng, exactly like the // The whole grain field is drawn serially from the rng, exactly like the
// scanlines plan, and only the pixel arithmetic afterwards is parallel. // scanlines plan, and only the pixel arithmetic afterwards is parallel.
const int cellsX = (width + size - 1) / size; const GrainField field = drawGrain(rng, width, height, size, colour);
const int cellsY = (height + size - 1) / size; applyGrain(canvas, field, amount, midtones);
const int channels = colour ? 3 : 1;
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
// by `amount` there keeps the whole thing in integers.
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
std::uint8_t* out = canvas.row(static_cast<int>(y));
const std::size_t rowCell =
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
for (int x = 0; x < width; ++x) {
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
for (int c = 0; c < 3; ++c) {
const int value = out[static_cast<std::size_t>(x) * 3 + c];
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
const int weight = midtones ? midtoneWeight(value) : 255;
const int delta = grain * weight / 255 * scale / 65536;
out[static_cast<std::size_t>(x) * 3 + c] =
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
}
}
}
});
return canvas; return canvas;
} }
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <vector>
#include "core/image.hpp"
#include "core/parallel.hpp"
#include "core/rng.hpp"
namespace effector {
// A field of grain over a canvas: one value in [-127, 127] per cell of
// `size` pixels square, per channel when the grain is coloured. Shared by the
// film grain and the film gate effects.
struct GrainField {
int size = 1;
int cellsX = 0;
bool colour = false;
std::vector<std::int8_t> cells;
};
// Draws the whole field serially from the rng, so that only the pixel
// arithmetic that applies it runs in parallel.
inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) {
GrainField field;
field.size = size;
field.colour = colour;
field.cellsX = (width + size - 1) / size;
const int cellsY = (height + size - 1) / size;
field.cells.resize(static_cast<std::size_t>(field.cellsX) * cellsY * (colour ? 3 : 1));
for (auto& cell : field.cells) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
return field;
}
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
// roughly how grain behaves on film.
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
// Pushes every pixel by its grain, at most `amount` of full scale, and less in
// the shadows and highlights when `midtones` is set.
inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) {
const int width = canvas.width();
const int channels = field.colour ? 3 : 1;
// 127 * 255 is the largest product of a grain and a midtone weight;
// scaling by `amount` there keeps the whole thing in integers.
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
std::uint8_t* out = canvas.row(static_cast<int>(y));
const std::size_t rowCell =
static_cast<std::size_t>(static_cast<int>(y) / field.size) * field.cellsX;
for (int x = 0; x < width; ++x) {
const std::size_t cell =
(rowCell + static_cast<std::size_t>(x / field.size)) * channels;
for (int c = 0; c < 3; ++c) {
const int value = out[static_cast<std::size_t>(x) * 3 + c];
const std::size_t plane = field.colour ? static_cast<std::size_t>(c) : 0;
const int grain = field.cells[cell + plane];
const int weight = midtones ? midtoneWeight(value) : 255;
const int delta = grain * weight / 255 * scale / 65536;
out[static_cast<std::size_t>(x) * 3 + c] =
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
}
}
}
});
}
} // namespace effector
+5
View File
@@ -2110,6 +2110,11 @@ int main() {
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}}, {"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {}, {"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
{{"sweep_line_chance", 0.0}}}, {{"sweep_line_chance", 0.0}}},
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
{"gate_scratches", 0L}},
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
{"gate_scratches", 0L}}},
}; };
for (const auto& [name, lively, smooth, off] : moving) { for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name); const auto effect = effectByName(name);