Split effects into image and video effects, with VHS tracking as the first video effect

Effect::video() says which kind an effect is. The Add effect menu lists the
two kinds in sections of their own, each scrolling on its own, and offers
video effects only in a video project; a video stage carries a film icon in
the chain and, in a still, is dimmed and skipped like a stage switched off.
enabledStages takes whether the render is a video and is the one place that
leaves video stages out of a still. --help groups the video effects under a
heading of their own, --list-effects marks them, and a video effect given
for a still is skipped with a warning.

VideoEffect is the base every video effect builds on, and keeps the rules a
moving stage has to follow: it takes one value from the shared rng, the same
on every frame, and hands the effect steady and per-frame generators and
loop-rounded time through a Moment, so a subclass writes only animate().
VHS tracking is the first, and the one to copy: the colour planes sway and
shake and a tracking bar rolls down the picture.

That motion moves out of channel split, which is an image effect again and
renders stills exactly as before; forVideo() goes with it. Version 0.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 19:03:39 +02:00
co-authored by Claude Opus 5.5
parent ea59db5acd
commit cdaadffc89
23 changed files with 752 additions and 376 deletions
+16 -9
View File
@@ -264,15 +264,22 @@ std::string usage(const char* programName) {
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
}
for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help)
<< (spec.videoOnly ? " (video only)" : "") << "\n";
// Image effects first, then the video effects under a heading of their
// own: those run only in a clip.
for (const bool video : {false, true}) {
if (video) {
out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
}
for (const auto& effect : effects()) {
if (effect->video() != video) continue;
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
}
}
}
return out.str();
+29 -29
View File
@@ -6,11 +6,13 @@
namespace effector {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video) {
std::vector<const ChainStage*> running;
running.reserve(stages.size());
for (const auto& stage : stages) {
if (stage.enabled && stage.effect) running.push_back(&stage);
if (stage.enabled && stage.effect && (video || !stage.effect->video())) {
running.push_back(&stage);
}
}
return running;
}
@@ -36,7 +38,7 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
namespace {
// The stages that run, checked: throws for a chain with nothing to render.
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
@@ -44,9 +46,17 @@ std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stag
// A disabled stage never reaches the render at all: it is not handed an
// image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added.
auto running = enabledStages(stages);
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
// back exactly the picture the chain made before it was added. A video
// effect in a still is left out the same way.
auto running = enabledStages(stages, video);
if (running.empty()) {
const bool videoLeft = std::any_of(stages.begin(), stages.end(), [](const ChainStage& stage) {
return stage.enabled && stage.effect->video();
});
throw EffectError(videoLeft ? "Video effects only run in a video, and the chain has "
"no image effect switched on."
: "Every effect in the chain is disabled.");
}
return running;
}
@@ -99,37 +109,27 @@ Image runStages(const std::vector<const ChainStage*>& running, std::size_t first
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng, const StyleSet& styles,
const FrameTime& frame) {
const auto running = runningStages(stages);
const auto running = runningStages(stages, frame.video());
ensureRenderFits(running, width, height, styles);
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
frame);
}
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles) {
for (const auto* stage : enabledStages(stages)) {
if (stage->effect->animated(stageValues(*stage, styles))) return true;
}
return false;
}
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, std::uint64_t seed, const StyleSet& styles,
const VideoSettings& video,
const std::function<bool(int index, Image&& frame)>& onFrame) {
const auto running = runningStages(stages);
const auto running = runningStages(stages, true);
ensureRenderFits(running, width, height, styles);
const int count = video.frameCount();
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
// Everything before the first stage that moves is the same on every
// frame -- the same seed, the same values, no time -- so it is rendered
// once, as frame 0, and picked up from there. The rng is copied along
// with the picture, which is what makes the shortcut exact.
// Everything before the first video effect is the same on every frame --
// the same seed, the same values, no time -- so it is rendered once, as
// frame 0, and picked up from there. The rng is copied along with the
// picture, which is what makes the shortcut exact.
std::size_t still = 0;
while (still < running.size() &&
!running[still]->effect->animated(stageValues(*running[still], styles))) {
++still;
}
while (still < running.size() && !running[still]->effect->video()) ++still;
Rng prefixRng(seed);
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
styles, FrameTime{0, count, fps});
@@ -146,24 +146,24 @@ int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>
return count;
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
const auto running = enabledStages(stages);
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
const auto running = enabledStages(stages, video);
if (running.empty()) return false;
return running.front()->effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string chainTitle(const std::vector<ChainStage>& stages, bool video) {
std::string text;
for (const auto* stage : enabledStages(stages)) {
for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += " → ";
text += stage->effect->title();
}
return text;
}
std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
std::string text;
for (const auto* stage : enabledStages(stages)) {
for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += "-";
text += stage->effect->name();
}
+14 -15
View File
@@ -40,11 +40,12 @@ struct ChainStage {
// that style carries. The one place a style is resolved.
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
// The stages that actually run, in order, as pointers into `stages`. A
// disabled stage draws nothing from the rng and is handed no image, so a chain
// renders exactly as it would if that stage had never been added -- this is
// the one place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
// The stages that actually run, in order, as pointers into `stages`: those
// switched on, and of those the video effects only in a video. A stage left
// out draws nothing from the rng and is handed no image, so a chain renders
// exactly as it would if that stage had never been added -- this is the one
// place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video);
// Renders the enabled stages in order. The first one sees the loaded sources;
// every later one sees the previous stage's output as its only source, so blur
@@ -55,21 +56,19 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
// `styles` is the project's pool; a chain built without one -- the command
// line's -- passes none and renders from the stages alone.
//
// `frame` says where in a video the render sits, a still by default; every
// stage is handed it, and only one that moves makes anything of it.
// `frame` says where in a video the render sits, a still by default, in
// which the video effects are skipped; every stage that runs is handed it,
// and only a video effect makes anything of it.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng, const StyleSet& styles = {},
const FrameTime& frame = {});
// Whether any stage that runs moves over time with the values it runs with.
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles = {});
// Renders every frame of a video, in order, handing each to `onFrame`; it
// returning false stops the render there. Frame k is exactly what
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} --
// every frame starts from the same seed, so a stage that does not move
// renders the same picture throughout rather than reshuffling on every
// frame. The stages before the first one that moves are rendered once, and
// frame. The stages before the first video effect are rendered once, and
// their picture and the rng state they leave are reused for every frame.
// Returns the number of frames handed out.
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
@@ -80,13 +79,13 @@ int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>
// Whether the chain has to be given source images. Only the first stage that
// runs ever sees them — every later one works on the stage before it — so this
// is simply whether that effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages);
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video);
// "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages);
// what was rendered, so a stage that did not run is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
// "scanlines-blur", for file names.
std::string chainSlug(const std::vector<ChainStage>& stages);
std::string chainSlug(const std::vector<ChainStage>& stages, bool video);
} // namespace effector
+12 -11
View File
@@ -58,21 +58,22 @@ public:
return title();
}
// Whether a stage with these values changes from one frame of a video to
// the next. A stage that does not is rendered once for the whole clip.
virtual bool animated(const ParamValues& values) const {
(void)values;
return false;
}
// Whether this is a video effect rather than an image effect. An image
// effect makes a picture and holds still in a clip; a video effect is
// motion and nothing else, so it is offered only in a video project and a
// still skips it whole, as it skips a stage that is switched off. Video
// effects derive from VideoEffect (effects/video_effect.hpp), which
// answers true here and keeps the rules a moving stage has to follow.
virtual bool video() const { return false; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
// One frame of a video, or a still when `frame` says so. An effect that
// does not move needs only render(), which this falls back to. One that
// does must leave `rng` in the same state on every frame -- a frame's own
// randomness comes from a generator of its own -- so the stages after it
// stay put while it moves.
// One frame of a video, or a still when `frame` says so. An image effect
// needs only render(), which this falls back to; a video effect renders
// here, and must leave `rng` in the same state on every frame -- a
// frame's own randomness comes from a generator of its own -- so the
// stages after it stay put while it moves.
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const {
(void)frame;
-6
View File
@@ -16,12 +16,6 @@ ParamSpec ParamSpec::styled() const {
return copy;
}
ParamSpec ParamSpec::forVideo() const {
ParamSpec copy = *this;
copy.videoOnly = true;
return copy;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
-7
View File
@@ -27,10 +27,6 @@ struct ParamSpec {
// Whether a named style carries this param rather than the stage it sits
// on: see core/style.hpp.
bool inStyle = false;
// Whether the param only means something in a video project -- how a
// stage moves over time. The interface leaves it out of a still project,
// where it would change nothing.
bool videoOnly = false;
std::string flag() const; // "strip_height" -> "--strip-height"
@@ -38,9 +34,6 @@ struct ParamSpec {
// reads makeDouble("font_size", ...).styled() and the factories keep the
// signatures they have.
ParamSpec styled() const;
// A copy of this spec marked as mattering only in a video project, read
// the same way: makeInt("split_jitter", ...).forVideo().
ParamSpec forVideo() const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1);
+3
View File
@@ -14,6 +14,7 @@
#include "effects/slitshift.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
#include "effects/tracking.hpp"
#include "effects/warp.hpp"
namespace effector {
@@ -36,6 +37,8 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<WarpEffect>(),
std::make_shared<BitCrushEffect>(),
std::make_shared<FeedbackEffect>(),
// Video effects, offered only in a video project.
std::make_shared<TrackingEffect>(),
};
return registry;
}
+8 -172
View File
@@ -2,8 +2,6 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <numbers>
#include <vector>
#include "core/parallel.hpp"
@@ -24,26 +22,6 @@ const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
"rgb moves all three channels apart; red_cyan moves red one way and "
"green and blue the other, as 3D glasses would; random moves one "
"channel picked at random."),
ParamSpec::makeInt("split_wobble", "Wobble", 4, 0, 256,
"How far in pixels the channels sway back and forth over time.")
.forVideo(),
ParamSpec::makeDouble("split_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
"Sways per second, rounded so that the clip loops without a jump.",
0.1, 1)
.forVideo(),
ParamSpec::makeInt("split_jitter", "Jitter", 2, 0, 256,
"Furthest in pixels the channels shake, anew on every frame and, in "
"per_band mode, for every band.")
.forVideo(),
ParamSpec::makeInt("split_roll", "Tracking bar", 16, 0, 512,
"How far in pixels a bar rolling down the picture tears the rows "
"sideways and the channels apart; 0 for none.")
.forVideo(),
ParamSpec::makeDouble("split_roll_time", "Bar pass", 4.0, 0.5, 60.0,
"Seconds the tracking bar takes from top to bottom, rounded so "
"that the clip loops without a jump.",
0.5, 1)
.forVideo(),
};
return specs;
}
@@ -57,68 +35,28 @@ struct Offset {
using ChannelOffsets = std::array<Offset, 3>;
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) {
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
ChannelOffsets offsets{};
auto draw = [&] {
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
static_cast<int>(rng.inRange(-amount, amount))};
};
moved = -1;
if (channels == "red_cyan") {
const Offset red = draw();
offsets[0] = red;
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
} else if (channels == "random") {
moved = static_cast<int>(rng.below(2));
offsets[static_cast<std::size_t>(moved)] = draw();
offsets[rng.below(2)] = draw();
} else {
for (auto& offset : offsets) offset = draw();
}
return offsets;
}
// A motion of each channel, handed out the way the mode moves channels: all
// three on their own, green and blue opposite red, or only the one channel
// that was picked -- so the motion never moves a channel the still left alone.
using Motion = std::array<double, 3>;
} // namespace
Motion spread(const Motion& motion, const std::string& channels, int moved) {
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]};
if (moved >= 0) {
Motion only{};
only[static_cast<std::size_t>(moved)] = motion[0];
return only;
}
return motion;
}
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
struct MotionSettings {
int wobble = 0;
double wobbleSpeed = 0.0;
int jitter = 0;
int roll = 0;
double rollTime = 1.0;
explicit MotionSettings(const ParamValues& values)
: wobble(static_cast<int>(std::max(0L, paramInt(values, "split_wobble", 4)))),
wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "split_jitter", 2)))),
roll(static_cast<int>(std::max(0L, paramInt(values, "split_roll", 16)))),
rollTime(std::max(0.5, paramDouble(values, "split_roll_time", 4.0))) {}
bool moves() const { return (wobble > 0 && wobbleSpeed > 0.0) || jitter > 0 || roll > 0; }
};
// Rounds a rate so a whole number of cycles fits the clip, never fewer than
// one: the last frame then leads straight back into the first.
double loopRate(double perSecond, double clipSeconds) {
return std::max(1.0, std::round(perSecond * clipSeconds)) / clipSeconds;
}
Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) {
Image ChannelSplitEffect::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.");
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
@@ -133,105 +71,20 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
const int band = perBand ? bandHeight : height;
const int bandCount = (height + band - 1) / band;
std::vector<ChannelOffsets> plan;
std::vector<int> moved;
plan.reserve(static_cast<std::size_t>(bandCount));
moved.reserve(static_cast<std::size_t>(bandCount));
for (int i = 0; i < bandCount; ++i) {
int only = -1;
plan.push_back(drawOffsets(rng, amount, channels, only));
moved.push_back(only);
}
// Every row's offsets, and a sideways shift of the whole row. A still is
// just the band plan; a frame of a video adds the motion to it.
std::vector<ChannelOffsets> rows(static_cast<std::size_t>(height));
std::vector<int> shift(static_cast<std::size_t>(height), 0);
for (int y = 0; y < height; ++y) rows[static_cast<std::size_t>(y)] = plan[static_cast<std::size_t>(y / band)];
const MotionSettings motion(values);
if (frame.video() && motion.moves()) {
// The same draws on every frame, after the still's: where each
// channel starts its sway and which way it sways. The shake is the
// one thing that differs between frames, so it comes from a generator
// of the frame's own, seeded by one more draw -- the shared rng leaves
// every frame in the same state, and the stages after this one hold
// still while it moves.
std::array<double, 3> phase{};
std::array<double, 3> angle{};
for (int c = 0; c < 3; ++c) {
phase[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
angle[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
}
Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame.index + 1)));
const double clip = static_cast<double>(std::max(1, frame.count)) / frame.fps;
const double t = frame.seconds();
Motion swayX{};
Motion swayY{};
if (motion.wobble > 0 && motion.wobbleSpeed > 0.0) {
const double rate = loopRate(motion.wobbleSpeed, clip);
for (std::size_t c = 0; c < 3; ++c) {
const double sway = motion.wobble * std::sin(2.0 * std::numbers::pi * rate * t + phase[c]);
swayX[c] = sway * std::cos(angle[c]);
swayY[c] = sway * std::sin(angle[c]);
}
}
// The tracking bar: an eighth of the picture, soft at both edges,
// entering above the top and leaving below the bottom.
const double barHeight = std::max(8.0, height / 8.0);
const double passes = loopRate(1.0 / motion.rollTime, clip);
const double along = std::fmod(t * passes, 1.0);
const double barTop = -barHeight + along * (height + barHeight);
for (int b = 0; b < bandCount; ++b) {
Motion jitterX{};
Motion jitterY{};
if (motion.jitter > 0) {
for (std::size_t c = 0; c < 3; ++c) {
jitterX[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter));
// Mostly sideways, as a picture losing its horizontal hold.
jitterY[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter)) / 3.0;
}
}
const int only = moved[static_cast<std::size_t>(b)];
const Motion dx = spread({swayX[0] + jitterX[0], swayX[1] + jitterX[1], swayX[2] + jitterX[2]},
channels, only);
const Motion dy = spread({swayY[0] + jitterY[0], swayY[1] + jitterY[1], swayY[2] + jitterY[2]},
channels, only);
// The bar pulls red one way and blue the other (or the moved
// channel alone), whatever the sway is doing.
const Motion apart = spread({1.0, 0.0, -1.0}, channels, only);
const int end = std::min(height, (b + 1) * band);
for (int y = b * band; y < end; ++y) {
double bar = 0.0;
if (motion.roll > 0 && y >= barTop && y < barTop + barHeight) {
bar = motion.roll * std::sin(std::numbers::pi * (y - barTop) / barHeight);
}
auto& offsets = rows[static_cast<std::size_t>(y)];
for (std::size_t c = 0; c < 3; ++c) {
offsets[c].dx += static_cast<int>(std::lround(dx[c] + bar * apart[c]));
offsets[c].dy += static_cast<int>(std::lround(dy[c]));
}
shift[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(bar / 2.0));
}
}
}
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
Image canvas(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 ChannelOffsets& offsets = rows[y];
const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int c = 0; c < 3; ++c) {
const Offset& offset = offsets[static_cast<std::size_t>(c)];
const int dx = offset.dx + shift[y];
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
const std::uint8_t* in = input.row(sy);
for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - dx, 0, width - 1);
const int sx = std::clamp(x - offset.dx, 0, width - 1);
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
}
}
@@ -241,21 +94,4 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
return canvas;
}
} // namespace
bool ChannelSplitEffect::animated(const ParamValues& values) const {
return MotionSettings(values).moves();
}
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
return renderSplit(sources, width, height, rng, values, FrameTime{});
}
Image ChannelSplitEffect::renderFrame(const std::vector<Image>& sources, int width, int height,
Rng& rng, const ParamValues& values,
const FrameTime& frame) const {
return renderSplit(sources, width, height, rng, values, frame);
}
} // namespace effector
-6
View File
@@ -6,9 +6,6 @@ namespace effector {
// Knocks the red, green and blue planes out of register, either the whole image
// at once or band by band, each band with offsets of its own.
// In a video the channels do not sit still: they sway, shake from frame to
// frame, and a tracking bar rolls down the picture as on a worn tape or an old
// tube. A still picture is rendered exactly as it always was.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class ChannelSplitEffect : public Effect {
@@ -21,12 +18,9 @@ public:
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
bool animated(const ParamValues& values) const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const override;
};
} // namespace effector
+147
View File
@@ -0,0 +1,147 @@
#include "effects/tracking.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <numbers>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& TrackingEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("tracking_wobble", "Wobble", 4, 0, 256,
"How far in pixels the colour planes sway apart and back."),
ParamSpec::makeDouble("tracking_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
"Sways per second, rounded so that the clip loops without a jump.",
0.1, 1),
ParamSpec::makeInt("tracking_jitter", "Jitter", 2, 0, 256,
"Furthest in pixels a band shakes, anew on every frame."),
ParamSpec::makeInt("tracking_band_height", "Band height", 24, 1, 2048,
"Thickness in pixels of the bands that shake on their own."),
ParamSpec::makeInt("tracking_bar", "Tracking bar", 16, 0, 512,
"How far in pixels the rolling bar tears the rows sideways and the "
"colour planes apart; 0 for none."),
ParamSpec::makeDouble("tracking_bar_time", "Bar pass", 4.0, 0.5, 60.0,
"Seconds the tracking bar takes from top to bottom, rounded so "
"that the clip loops without a jump.",
0.5, 1),
};
return specs;
}
namespace {
struct Settings {
int wobble;
double wobbleSpeed;
int jitter;
int band;
int bar;
double barTime;
explicit Settings(const ParamValues& values)
: wobble(static_cast<int>(std::max(0L, paramInt(values, "tracking_wobble", 4)))),
wobbleSpeed(std::max(0.0, paramDouble(values, "tracking_wobble_speed", 0.5))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "tracking_jitter", 2)))),
band(static_cast<int>(std::max(1L, paramInt(values, "tracking_band_height", 24)))),
bar(static_cast<int>(std::max(0L, paramInt(values, "tracking_bar", 16)))),
barTime(std::max(0.5, paramDouble(values, "tracking_bar_time", 4.0))) {}
};
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
// How far each of the red, green and blue planes is moved on one row.
struct RowShift {
std::array<int, 3> dx{};
std::array<int, 3> dy{};
};
} // namespace
Image TrackingEffect::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 plane's sway starts, and
// the direction it sways in.
Rng steady = at.steady();
std::array<double, 3> phase{};
std::array<double, 3> angle{};
for (std::size_t c = 0; c < 3; ++c) {
phase[c] = unit(steady) * tau;
angle[c] = unit(steady) * tau;
}
// Where this frame is in time: the sway of each plane, and the bar -- an
// eighth of the picture, soft at both edges, entering above the top and
// leaving below the bottom.
std::array<double, 3> swayX{};
std::array<double, 3> swayY{};
if (settings.wobble > 0 && settings.wobbleSpeed > 0.0) {
const double turn = at.cycle(settings.wobbleSpeed) * tau;
for (std::size_t c = 0; c < 3; ++c) {
const double sway = settings.wobble * std::sin(turn + phase[c]);
swayX[c] = sway * std::cos(angle[c]);
swayY[c] = sway * std::sin(angle[c]);
}
}
const double barHeight = std::max(8.0, height / 8.0);
const double barTop = -barHeight + at.cycle(1.0 / settings.barTime) * (height + barHeight);
// What changes on every frame: each band's shake, mostly sideways, as a
// picture losing its horizontal hold. All of it drawn, band by band,
// before a pixel is touched.
Rng fresh = at.fresh();
std::vector<RowShift> rows(static_cast<std::size_t>(height));
std::vector<int> tear(static_cast<std::size_t>(height), 0);
for (int top = 0; top < height; top += settings.band) {
std::array<double, 3> jitterX{};
std::array<double, 3> jitterY{};
if (settings.jitter > 0) {
for (std::size_t c = 0; c < 3; ++c) {
jitterX[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter));
jitterY[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter)) / 3.0;
}
}
const int end = std::min(height, top + settings.band);
for (int y = top; y < end; ++y) {
// The bar pulls red one way and blue the other, and drags the
// whole row half as far.
double pull = 0.0;
if (settings.bar > 0 && y >= barTop && y < barTop + barHeight) {
pull = settings.bar * std::sin(std::numbers::pi * (y - barTop) / barHeight);
}
constexpr std::array<double, 3> apart = {1.0, 0.0, -1.0};
auto& row = rows[static_cast<std::size_t>(y)];
for (std::size_t c = 0; c < 3; ++c) {
row.dx[c] = static_cast<int>(std::lround(swayX[c] + jitterX[c] + pull * apart[c]));
row.dy[c] = static_cast<int>(std::lround(swayY[c] + jitterY[c]));
}
tear[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(pull / 2.0));
}
}
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) {
std::uint8_t* out = frame.row(static_cast<int>(y));
for (std::size_t c = 0; c < 3; ++c) {
const int dx = rows[y].dx[c] + tear[y];
const int sy = std::clamp(static_cast<int>(y) - rows[y].dy[c], 0, height - 1);
const std::uint8_t* in = input.row(sy);
for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - dx, 0, width - 1);
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
}
}
}
});
return frame;
}
} // namespace effector
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "effects/video_effect.hpp"
namespace effector {
// A worn tape losing its hold: the red, green and blue planes sway apart and
// back, bands of the picture shake sideways from frame to frame, and a
// tracking bar rolls down the picture tearing the rows it passes.
//
// The first video effect, and the one to copy for the next: its params are
// declared like any effect's, and animate() reads them, takes what stays put
// from at.steady(), what changes every frame from at.fresh(), and time from
// at.cycle() -- then draws the frame in one pass over the pixels.
class TrackingEffect : public VideoEffect {
public:
std::string name() const override { return "tracking"; }
std::string title() const override { return "VHS tracking"; }
std::string description() const override {
return "A worn tape losing its hold: the colour planes sway and shake, and a tracking bar "
"rolls down the picture.";
}
const std::vector<ParamSpec>& params() const override;
protected:
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
};
} // namespace effector
+47
View File
@@ -0,0 +1,47 @@
#include "effects/video_effect.hpp"
#include <algorithm>
#include <cmath>
#include "core/video.hpp"
#include "effects/source_fill.hpp"
namespace effector {
double Moment::length() const noexcept {
return static_cast<double>(std::max(1, frame_.count)) / std::max(1, frame_.fps);
}
double Moment::loopRate(double perSecond) const noexcept {
const double clip = length();
return std::max(1.0, std::round(perSecond * clip)) / clip;
}
double Moment::cycle(double perSecond) const noexcept {
return std::fmod(seconds() * loopRate(perSecond), 1.0);
}
Rng Moment::fresh() const noexcept {
return Rng(seed_ ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame_.index + 1)));
}
Image VideoEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
return renderFrame(sources, width, height, rng, values, FrameTime{0, kDefaultFps, kDefaultFps});
}
Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const {
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};
// 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
// effect draws comes from.
Image input = canvasFromSources(sources, width, height, rng);
const std::uint64_t seed = rng.next();
return animate(std::move(input), values, Moment(at, seed));
}
} // namespace effector
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <cstdint>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Where one frame sits in its clip, and the randomness it may draw on, as a
// video effect is handed it. Time is always read through here: the rates it
// hands out are rounded to fit the clip, so whatever an effect does with
// them, the last frame leads straight back into the first and a clip loops
// without a jump.
class Moment {
public:
Moment(const FrameTime& frame, std::uint64_t seed) noexcept : frame_(frame), seed_(seed) {}
int index() const noexcept { return frame_.index; }
int frames() const noexcept { return frame_.count; }
int fps() const noexcept { return frame_.fps; }
double seconds() const noexcept { return frame_.seconds(); } // into the clip
double length() const noexcept; // of the whole clip
// The rate nearest `perSecond` at which a whole number of cycles fits the
// clip, and never fewer than one.
double loopRate(double perSecond) const noexcept;
// How far through a cycle that happens `perSecond` times a second this
// frame is, from 0 at its start towards 1 at its end: the one number most
// motion needs -- sin(2π · cycle) sways, cycle · height rolls down.
double cycle(double perSecond) const noexcept;
// Draws that come out the same on every frame of the clip: where a sway
// starts, which way it goes -- what makes the motion this seed's own.
Rng steady() const noexcept { return Rng(seed_); }
// Draws that are new on every frame: shake, noise, flicker. The same
// frame of the same seed still draws the same values, so frames can be
// rendered alone and in any order.
Rng fresh() const noexcept;
private:
FrameTime frame_;
std::uint64_t seed_;
};
// The base of every video effect, and the one place that keeps the rules a
// moving stage has to follow, so an effect built on it is only its look:
//
// - It takes one value from the shared rng -- the same on every frame -- and
// 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
// while it moves.
// - It is a transform: it needs no source images, and leading a chain it
// starts from a crop of one, as the other transforms do.
// - 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.
//
// A new video effect subclasses this, declares its params like any effect,
// and writes animate(); see TrackingEffect for one.
class VideoEffect : public Effect {
public:
bool video() const final { return true; }
bool needsSources() const final { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const final;
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const final;
protected:
// 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.
// Every random value comes from `at`, never from anywhere else.
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const = 0;
};
} // namespace effector
+111 -39
View File
@@ -381,7 +381,7 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesSection_.set_visible(chainNeedsSources(stages_));
sourcesSection_.set_visible(chainNeedsSources(stages_, isVideo()));
}
// The heading carries the count, which a long list scrolled away would hide.
@@ -608,7 +608,9 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
suspendVideoSync_ = suspended;
playbackBar_.set_visible(false);
clearClip();
buildParamWidgets(); // the motion params come and go with the switch
updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets();
}
void EffectorWindow::onVideoToggled() {
@@ -619,6 +621,8 @@ void EffectorWindow::onVideoToggled() {
result_ = Image();
clearClip();
saveButton_.set_sensitive(false);
updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets();
requestRender();
}
@@ -805,8 +809,11 @@ void EffectorWindow::buildParamWidgets() {
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
const bool skipped = stage.effect->video() && !isVideo();
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
(stage.enabled ? " settings" : " settings (off)"));
(!stage.enabled ? " settings (off)"
: skipped ? " settings (video only)"
: " settings"));
int row = 0;
// The style the stage follows, when its effect takes one at all: the
@@ -829,9 +836,6 @@ void EffectorWindow::buildParamWidgets() {
}
for (const auto& spec : stage.effect->params()) {
// How a stage moves means nothing in a still, so it is left out there;
// the values stay with the stage for when the project becomes a video.
if (spec.videoOnly && !isVideo()) continue;
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
@@ -1707,39 +1711,86 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
return *box;
}
// The tallest the menu of effects grows before it scrolls, in pixels.
constexpr int addMenuMaxHeight = 480;
// The tallest each part of the menu of effects grows before it scrolls, in
// pixels: together, with their headings, short enough to open below a button
// near the top of a small screen.
constexpr int addMenuImageHeight = 330;
constexpr int addMenuVideoHeight = 110;
// One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it.
// file knowing anything about it. The image effects and the video effects
// are two sections, each under its own heading and each scrolling on its own,
// so the video effects stay in sight however many image effects there are;
// they can only be picked in a video project.
Gtk::Widget& EffectorWindow::buildAddMenu() {
auto* popover = Gtk::make_managed<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
auto* sections = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
list->append(*item);
for (const bool video : {false, true}) {
if (video) {
auto* separator = Gtk::make_managed<Gtk::Separator>();
separator->set_margin_top(6);
separator->set_margin_bottom(4);
sections->append(*separator);
}
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
heading->set_margin_start(8);
heading->set_margin_top(4);
heading->set_margin_bottom(2);
heading->append(*Gtk::make_managed<Gtk::Image>(Gio::ThemedIcon::create(
video ? "camera-video-symbolic" : "camera-photo-symbolic")));
auto* title = Gtk::make_managed<Gtk::Label>(video ? "Video effects" : "Image effects");
title->add_css_class("heading");
heading->append(*title);
sections->append(*heading);
if (video) {
videoEffectsHint_ = Gtk::make_managed<Gtk::Label>("Switch on Video under Setup to add these.");
videoEffectsHint_->add_css_class("dim-label");
videoEffectsHint_->set_xalign(0.0F);
videoEffectsHint_->set_margin_start(8);
videoEffectsHint_->set_wrap(true);
videoEffectsHint_->set_max_width_chars(28);
sections->append(*videoEffectsHint_);
}
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
if (effect->video() != video) continue;
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
if (video) videoEffectItems_.push_back(item);
list->append(*item);
}
// A popover that does not fit on the screen is not shown at all, and
// the lists grow with every effect registered, so past a point they
// scroll.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(video ? addMenuVideoHeight : addMenuImageHeight);
scroller->set_child(*list);
sections->append(*scroller);
}
updateVideoEffectItems();
// A popover that does not fit on the screen is not shown at all, and the
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
popover->set_child(*sections);
return *popover;
}
// A video effect has nothing to do in a still, so it is offered only once the
// project is a video.
void EffectorWindow::updateVideoEffectItems() {
for (auto* item : videoEffectItems_) item->set_sensitive(isVideo());
if (videoEffectsHint_ != nullptr) videoEffectsHint_->set_visible(!isVideo());
}
void EffectorWindow::appendStage(const EffectPtr& effect) {
stages_.push_back({effect, effect->defaults()});
selectedStage_ = static_cast<int>(stages_.size()) - 1;
@@ -1782,7 +1833,8 @@ void EffectorWindow::rebuildChainRows() {
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const bool video = isVideo();
const auto running = enabledStages(stages_, video);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
@@ -1809,13 +1861,28 @@ void EffectorWindow::rebuildChainRows() {
});
box->append(*check);
// A video effect is marked as one, and in a still it is dimmed like a
// stage that is switched off: the render skips it either way.
const bool videoEffect = stages_[i].effect->video();
const bool skipped = !stages_[i].enabled || (videoEffect && !video);
if (videoEffect) {
auto* icon = Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("camera-video-symbolic"));
icon->set_tooltip_text("A video effect");
icon->set_margin_start(4);
icon->set_margin_end(2);
if (skipped) icon->add_css_class("dim-label");
box->append(*icon);
}
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
stageLabels_.push_back(label);
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
if (!stages_[i].enabled) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
if (skipped) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: skipped ? "A video effect: skipped in a still picture"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
box->append(*label);
@@ -2053,13 +2120,18 @@ void EffectorWindow::onRecreate() {
// ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded.
bool EffectorWindow::canRender() const {
if (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
if (enabledStages(stages_, isVideo()).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_, isVideo());
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
if (enabledStages(stages_, isVideo()).empty()) {
if (!enabledStages(stages_, true).empty()) {
return "Video effects run only in a video: switch on Video under Setup.";
}
return "Every effect is switched off.";
}
return "Add source images under Setup to start.";
}
@@ -2203,7 +2275,7 @@ void EffectorWindow::onRenderFinished() {
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
const std::size_t effects = enabledStages(stages_).size();
const std::size_t effects = enabledStages(stages_, wasVideo).size();
if (wasVideo) {
result_ = Image();
clip_ = std::move(incomingClip_);
@@ -2226,7 +2298,7 @@ void EffectorWindow::onRenderFinished() {
// The line itself stays short; the full chain and setup go in the tooltip.
std::ostringstream details;
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
details << chainTitle(stages_, wasVideo) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
<< static_cast<int>(heightSpin_.get_value()) << " · ";
if (wasVideo) {
const auto video = videoSettings();
@@ -2610,7 +2682,7 @@ void EffectorWindow::onSaveProjectAs() {
dialog->set_title("Save project");
dialog->set_filters(projectFilters());
dialog->set_initial_name(projectPath_.empty()
? "effector-" + chainSlug(stages_) + ".json"
? "effector-" + chainSlug(stages_, isVideo()) + ".json"
: std::filesystem::path(projectPath_).filename().string());
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveProjectChosen(result, dialog);
@@ -2662,7 +2734,7 @@ void EffectorWindow::onSave() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title(video ? "Save video" : "Save image");
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
dialog->set_initial_name("effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) +
(video ? ".mp4" : ".png"));
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
+5
View File
@@ -118,6 +118,7 @@ private:
Gtk::Widget& buildChainSection();
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
void updateVideoEffectItems(); // offers the video effects in a video only
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int from, int to); // `to` is the index it ends up at
@@ -307,6 +308,10 @@ private:
Gtk::Entry seedEntry_;
Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_;
// The entries of the video effects in its menu, and the line saying why
// they cannot be picked in a still; both owned by the menu.
std::vector<Gtk::Button*> videoEffectItems_;
Gtk::Label* videoEffectsHint_ = nullptr;
Gtk::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
+17 -5
View File
@@ -42,8 +42,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
std::cout << options.output << " " << options.width << "x" << options.height << " "
<< count << " frames at " << options.video.fps << " fps effect="
<< effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed
<< std::setprecision(1) << seconds << " s\n";
<< effector::chainSlug(stages, true) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << seconds << " s\n";
return 0;
}
@@ -71,6 +71,17 @@ int renderHeadless(const effector::Options& options) {
stages.push_back({effect, effect->defaults()});
}
// A video effect has nothing to do in a still; the render skips it, and
// says so here rather than leave it looking forgotten.
if (!options.video.enabled) {
for (const auto& stage : stages) {
if (!stage.enabled || !stage.effect->video()) continue;
std::cerr << "effector: skipping " << stage.effect->name()
<< ": a video effect, and this is a still (give --duration or "
"--video-preset for a clip)\n";
}
}
std::vector<effector::LoadFailure> failures;
// Decoding a batch of photographs takes seconds; on a terminal keep a
@@ -91,7 +102,7 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
}
if (sources.empty() && effector::chainNeedsSources(stages)) {
if (sources.empty() && effector::chainNeedsSources(stages, options.video.enabled)) {
std::cerr << "effector: no usable source images\n";
return 2;
}
@@ -108,7 +119,7 @@ int renderHeadless(const effector::Options& options) {
image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
<< " effect=" << effector::chainSlug(stages, false) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0;
}
@@ -129,7 +140,8 @@ int main(int argc, char** argv) {
}
if (options.listEffects) {
for (const auto& effect : effector::effects()) {
std::cout << effect->name() << "\t" << effect->title() << " - "
std::cout << effect->name() << "\t" << effect->title()
<< (effect->video() ? " (video)" : "") << " - "
<< effect->description() << "\n";
}
return 0;