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:
+16
-9
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user