Merge branch 'sidebar-chain-first'

This commit is contained in:
Si
2026-09-18 16:55:22 +02:00
15 changed files with 667 additions and 59 deletions
+2
View File
@@ -28,6 +28,8 @@ add_library(effector_core STATIC
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/effects/blur.cpp src/effects/blur.cpp
src/effects/grain.cpp
src/effects/monochrome.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
+9 -3
View File
@@ -24,7 +24,10 @@ bool parseSize(const std::string& text, int& width, int& height) {
long w = 0; long w = 0;
long h = 0; long h = 0;
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false; if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
if (w <= 0 || h <= 0) return false; // Checked as longs, before the narrowing: 4294968296 cast straight to int
// is 1000, which would render a plausible canvas nobody asked for.
if (w < kMinCanvasSide || w > kMaxCanvasSide) return false;
if (h < kMinCanvasSide || h > kMaxCanvasSide) return false;
width = static_cast<int>(w); width = static_cast<int>(w);
height = static_cast<int>(h); height = static_cast<int>(h);
return true; return true;
@@ -66,7 +69,9 @@ Options parseOptions(int argc, char** argv) {
} else if (arg == "-s" || arg == "--size") { } else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg); const std::string value = requireValue(argc, argv, i, arg);
if (!parseSize(value, options.width, options.height)) { if (!parseSize(value, options.width, options.height)) {
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080"); throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
"each side between " + std::to_string(kMinCanvasSide) + " and " +
std::to_string(kMaxCanvasSide));
} }
} else if (arg == "--seed") { } else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg); const std::string value = requireValue(argc, argv, i, arg);
@@ -131,7 +136,8 @@ std::string usage(const char* programName) {
<< "Options:\n" << "Options:\n"
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n" << " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n" << " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n" << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
<< kMaxCanvasSide << " (default: 1920x1080)\n"
<< " --seed N random seed (default: random)\n" << " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n" << " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n"; << " -h, --help show this help and exit\n";
+7
View File
@@ -10,6 +10,13 @@
namespace effector { namespace effector {
// The canvas sides both front-ends accept. The upper bound is not a hardware
// limit but the point past which a side stops fitting comfortably in an int
// once multiplied out, so both the CLI and the project reader can reject a
// number rather than silently wrap it into a plausible-looking one.
inline constexpr int kMinCanvasSide = 1;
inline constexpr int kMaxCanvasSide = 20000;
// One link of a cascade: an effect together with the values it runs with. // One link of a cascade: an effect together with the values it runs with.
struct ChainStage { struct ChainStage {
EffectPtr effect; EffectPtr effect;
+17 -3
View File
@@ -283,6 +283,21 @@ std::string relativeTo(const std::string& path, const std::string& directory) {
return text.rfind("..", 0) == 0 ? path : text; return text.rfind("..", 0) == 0 ? path : text;
} }
// A canvas side is checked as a double, before it is narrowed: casting a
// number outside the range of an int is undefined, and even where it wraps
// quietly it turns a size the file never named into one that looks ordinary.
int canvasSide(const Json& value, const char* what) {
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
throw EffectError(std::string("the canvas ") + what + " must be a whole number");
}
if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) {
throw EffectError(std::string("the canvas ") + what + " must be between " +
std::to_string(kMinCanvasSide) + " and " +
std::to_string(kMaxCanvasSide) + " pixels");
}
return static_cast<int>(value.number);
}
std::string resolve(const std::string& path, const std::string& directory) { std::string resolve(const std::string& path, const std::string& directory) {
std::filesystem::path file(path); std::filesystem::path file(path);
if (file.is_absolute() || directory.empty()) return path; if (file.is_absolute() || directory.empty()) return path;
@@ -350,12 +365,11 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
Project project; Project project;
if (const auto* canvas = root.member("canvas")) { if (const auto* canvas = root.member("canvas")) {
if (const auto* width = canvas->member("width")) project.width = static_cast<int>(width->number); if (const auto* width = canvas->member("width")) project.width = canvasSide(*width, "width");
if (const auto* height = canvas->member("height")) { if (const auto* height = canvas->member("height")) {
project.height = static_cast<int>(height->number); project.height = canvasSide(*height, "height");
} }
} }
if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive");
if (const auto* seed = root.member("seed")) { if (const auto* seed = root.member("seed")) {
if (seed->kind == Json::Kind::Number) { if (seed->kind == Json::Kind::Number) {
+4
View File
@@ -1,6 +1,8 @@
#include "core/registry.hpp" #include "core/registry.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace effector { namespace effector {
@@ -10,6 +12,8 @@ const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = { static const std::vector<EffectPtr> registry = {
std::make_shared<ScanlinesEffect>(), std::make_shared<ScanlinesEffect>(),
std::make_shared<BlurEffect>(), std::make_shared<BlurEffect>(),
std::make_shared<GrainEffect>(),
std::make_shared<MonochromeEffect>(),
}; };
return registry; return registry;
} }
+1 -25
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -24,31 +25,6 @@ const std::vector<ParamSpec>& BlurEffect::params() const {
namespace { namespace {
// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so
// that every pixel is covered even when the source is smaller than the canvas.
void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
const int width = canvas.width();
const std::size_t total = static_cast<std::size_t>(width) * 3;
const int copyWidth = std::min(width, src.width() - sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const int srcY = sy + (static_cast<int>(y) % std::max(1, src.height() - sy));
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
std::uint8_t* out = canvas.row(static_cast<int>(y));
std::memcpy(out, in, copyBytes);
std::size_t filled = copyBytes;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
});
}
// One separable box blur pass. `stride` is the distance in bytes between two // One separable box blur pass. `stride` is the distance in bytes between two
// neighbouring samples along the blurred axis, `count` how many of them there // neighbouring samples along the blurred axis, `count` how many of them there
// are; the window is clamped at both ends. // are; the window is clamped at both ends.
+90
View File
@@ -0,0 +1,90 @@
#include "effects/grain.hpp"
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& GrainEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0,
"How far a grain may push a pixel, as a fraction of full scale."),
ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32,
"Width in pixels of one grain; larger sizes look coarser."),
ParamSpec::makeBool("grain_colour", "Coloured grain", false,
"Draw the channels separately instead of one grey grain per pixel."),
ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true,
"Fade the grain out in the shadows and highlights, as film does."),
};
return specs;
}
namespace {
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
// roughly how grain behaves on film.
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
} // namespace
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0);
const int size = static_cast<int>(std::max(1L, paramInt(values, "grain_size", 1)));
const bool colour = paramBool(values, "grain_colour", false);
const bool midtones = paramBool(values, "grain_midtones", true);
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
if (amount <= 0.0) return canvas;
// The whole grain field is drawn serially from the rng, exactly like the
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
const int cellsX = (width + size - 1) / size;
const int cellsY = (height + size - 1) / size;
const int channels = colour ? 3 : 1;
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
// by `amount` there keeps the whole thing in integers.
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
std::uint8_t* out = canvas.row(static_cast<int>(y));
const std::size_t rowCell =
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
for (int x = 0; x < width; ++x) {
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
for (int c = 0; c < 3; ++c) {
const int value = out[static_cast<std::size_t>(x) * 3 + c];
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
const int weight = midtones ? midtoneWeight(value) : 255;
const int delta = grain * weight / 255 * scale / 65536;
out[static_cast<std::size_t>(x) * 3 + c] =
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
}
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Scatters film-like grain over its input. Like the blur it needs no source
// images of its own, so it can lead a chain: given sources it fills the canvas
// from one of them first, given none it grains an empty canvas — which with the
// midtone weighting on stays black, exactly as an unexposed film would.
class GrainEffect : public Effect {
public:
std::string name() const override { return "grain"; }
std::string title() const override { return "Film grain"; }
std::string description() const override {
return "Scatters film-like grain over its input, optionally coloured and weighted towards "
"the midtones; with sources loaded it fills the canvas from a randomly chosen one "
"first.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+105
View File
@@ -0,0 +1,105 @@
#include "effects/monochrome.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& MonochromeEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeChoice("mono_weighting", "Weighting", "luminance",
{"luminance", "average", "red", "green", "blue"},
"How the three channels are mixed into one grey; the colour names "
"weigh that channel most, like a filter over the lens."),
ParamSpec::makeDouble("mono_contrast", "Contrast", 1.0, 0.1, 4.0,
"Pull of the greys away from mid grey; 1 leaves them as they are."),
ParamSpec::makeChoice("mono_tone", "Toner", "none",
{"none", "sepia", "selenium", "cyanotype"},
"Tints the finished greys, as a darkroom toner would."),
ParamSpec::makeDouble("mono_strength", "Strength", 1.0, 0.0, 1.0,
"How far the result is mixed back over the colour input."),
};
return specs;
}
namespace {
// Channel weights summing to 1024, so a grey is one integer multiply-add away.
std::array<int, 3> weightsFor(const std::string& weighting) {
if (weighting == "average") return {341, 342, 341};
if (weighting == "red") return {819, 154, 51};
if (weighting == "green") return {154, 819, 51};
if (weighting == "blue") return {154, 51, 819};
return {218, 732, 74}; // Rec. 709 luminance.
}
// Tint multipliers per channel, as 256ths.
std::array<int, 3> tintFor(const std::string& tone) {
if (tone == "sepia") return {282, 251, 205};
if (tone == "selenium") return {261, 251, 271};
if (tone == "cyanotype") return {205, 243, 307};
return {256, 256, 256};
}
} // namespace
Image MonochromeEffect::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 auto weights = weightsFor(paramChoice(values, "mono_weighting", "luminance"));
const auto tint = tintFor(paramChoice(values, "mono_tone", "none"));
const double contrast = std::clamp(paramDouble(values, "mono_contrast", 1.0), 0.1, 4.0);
const double strength = std::clamp(paramDouble(values, "mono_strength", 1.0), 0.0, 1.0);
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
if (strength <= 0.0) return canvas;
// Contrast and toner only ever see one grey, so the whole tail of the
// conversion is a table: grey in, the three toned channels out.
std::array<std::array<std::uint8_t, 256>, 3> table{};
for (int grey = 0; grey < 256; ++grey) {
const int pulled =
std::clamp(static_cast<int>(128.0 + (grey - 128) * contrast + 0.5), 0, 255);
for (int c = 0; c < 3; ++c) {
table[static_cast<std::size_t>(c)][static_cast<std::size_t>(grey)] =
static_cast<std::uint8_t>(
std::clamp(pulled * tint[static_cast<std::size_t>(c)] / 256, 0, 255));
}
}
const int mix = static_cast<int>(strength * 256.0 + 0.5);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
const int grey =
(px[0] * weights[0] + px[1] * weights[1] + px[2] * weights[2]) / 1024;
for (int c = 0; c < 3; ++c) {
const int toned = table[static_cast<std::size_t>(c)]
[static_cast<std::size_t>(std::clamp(grey, 0, 255))];
px[c] = static_cast<std::uint8_t>(px[c] + (toned - px[c]) * mix / 256);
}
}
}
});
return canvas;
}
} // namespace effector
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Converts its input to black and white, with the choice of channel weighting
// a photographer would make behind the lens (a red filter darkening a sky, for
// instance), an optional contrast pull and an optional toner. Like the blur it
// needs no source images of its own, so it can lead a chain; given sources it
// fills the canvas from one of them first.
class MonochromeEffect : public Effect {
public:
std::string name() const override { return "monochrome"; }
std::string title() const override { return "Black and white"; }
std::string description() const override {
return "Converts its input to black and white with a choice of channel weighting, an "
"optional contrast pull and an optional toner; with sources loaded it fills the "
"canvas from a randomly chosen one first.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <algorithm>
#include <cstring>
#include "core/image.hpp"
#include "core/parallel.hpp"
namespace effector {
// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so
// that every pixel is covered even when the source is smaller than the canvas.
// Shared by the effects that do not build an image of their own but still want
// something to work on when they lead a chain.
inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
const int width = canvas.width();
const std::size_t total = static_cast<std::size_t>(width) * 3;
const int copyWidth = std::min(width, src.width() - sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const int srcY = sy + (static_cast<int>(y) % std::max(1, src.height() - sy));
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
std::uint8_t* out = canvas.row(static_cast<int>(y));
std::memcpy(out, in, copyBytes);
std::size_t filled = copyBytes;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
});
}
} // namespace effector
+66 -27
View File
@@ -85,13 +85,6 @@ void EffectorWindow::buildUi() {
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu())); menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
header->pack_end(menuButton_); header->pack_end(menuButton_);
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_icon_name("list-add-symbolic");
addButton->set_tooltip_text("Add images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
header->pack_start(*addButton);
addButton_ = addButton;
saveButton_.set_sensitive(false); saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave)); saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
header->pack_end(saveButton_); header->pack_end(saveButton_);
@@ -120,33 +113,73 @@ void EffectorWindow::buildUi() {
root->append(*paned); root->append(*paned);
} }
// The chain is what the interface is about, so it comes first and the params
// of the selected stage sit right below it. The sources are a prerequisite,
// not the subject: they live at the bottom in a collapsed expander.
Gtk::Widget& EffectorWindow::buildSidebar() { Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12); auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12); sidebar->set_margin(12);
sidebar->append(*sectionLabel("Source images")); sidebar->append(buildChainSection());
paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading");
sidebar->append(paramHeading_);
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
paramScroller->set_child(paramGrid_);
paramScroller->set_vexpand(true);
sidebar->append(*paramScroller);
rebuildChainRows();
buildParamWidgets();
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE); sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list"); sourceList_.add_css_class("boxed-list");
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet"); auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
empty->set_margin_top(18); empty->set_margin_top(12);
empty->set_margin_bottom(18); empty->set_margin_bottom(12);
sourceList_.set_placeholder(*empty); sourceList_.set_placeholder(*empty);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>(); auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_); scroller->set_child(sourceList_);
scroller->set_size_request(-1, 170); scroller->set_size_request(-1, 140);
scroller->set_vexpand(true); scroller->set_margin_top(6);
sidebar->append(*scroller);
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
sourcesBox->append(*scroller);
// Same shape as "Add effect": the icon and the text are put together by
// hand because set_label() and set_icon_name() replace one another.
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
addBox->append(*Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("list-add-symbolic")));
addBox->append(*Gtk::make_managed<Gtk::Label>("Add images"));
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_child(*addBox);
addButton->set_halign(Gtk::Align::START);
addButton->set_tooltip_text("Add source images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
sourcesBox->append(*addButton);
addButton_ = addButton;
sourcesExpander_.set_child(*sourcesBox);
sourcesExpander_.set_expanded(false);
updateSourcesLabel();
sidebar->append(sourcesExpander_);
sidebar->append(*sectionLabel("Canvas")); sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>(); auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8); grid->set_column_spacing(8);
grid->set_row_spacing(6); grid->set_row_spacing(6);
widthSpin_.set_range(1, 20000); widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
widthSpin_.set_increments(1, 100); widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920); widthSpin_.set_value(1920);
heightSpin_.set_range(1, 20000); heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
heightSpin_.set_increments(1, 100); heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080); heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) { for (auto* spin : {&widthSpin_, &heightSpin_}) {
@@ -163,17 +196,6 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
grid->attach(heightSpin_, 1, 1); grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid); sidebar->append(*grid);
sidebar->append(buildChainSection());
paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading");
sidebar->append(paramHeading_);
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
sidebar->append(paramGrid_);
rebuildChainRows();
buildParamWidgets();
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8); auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed"); auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
seedLabel->set_xalign(0.0F); seedLabel->set_xalign(0.0F);
@@ -187,6 +209,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
return *sidebar; return *sidebar;
} }
// The expander is collapsed by default, so its label carries the count that
// used to be plain to see in the list.
void EffectorWindow::updateSourcesLabel() {
int count = 0;
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
sourcesExpander_.set_label(count == 0 ? "Source images"
: "Source images (" + std::to_string(count) + ")");
}
Gtk::Widget& EffectorWindow::buildPreview() { Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true); picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN); picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
@@ -565,6 +596,7 @@ void EffectorWindow::appendSourceRow(const std::string& path) {
row->set_child(*box); row->set_child(*box);
sourceList_.append(*row); sourceList_.append(*row);
updateSourcesLabel();
} }
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
@@ -575,6 +607,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
} }
}); });
sourceList_.remove(*row); sourceList_.remove(*row);
updateSourcesLabel();
requestRender(); requestRender();
} }
@@ -622,7 +655,12 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
chainList_.set_placeholder(*empty); chainList_.set_placeholder(*empty);
chainList_.signal_row_selected().connect( chainList_.signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { onStageSelected(); }); [this](Gtk::ListBoxRow*) { onStageSelected(); });
box->append(chainList_);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(chainList_);
scroller->set_size_request(-1, 150);
box->append(*scroller);
// set_label() and set_icon_name() replace one another, so the icon and the // set_label() and set_icon_name() replace one another, so the icon and the
// text are put together by hand. // text are put together by hand.
@@ -995,6 +1033,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
void EffectorWindow::clearSources() { void EffectorWindow::clearSources() {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
updateSourcesLabel();
mutateSources([](SourceList& list) { list.clear(); }); mutateSources([](SourceList& list) { list.clear(); });
loadQueue_.clear(); loadQueue_.clear();
if (loading_) dropLoadedImages_ = true; if (loading_) dropLoadedImages_ = true;
+2
View File
@@ -48,6 +48,7 @@ private:
void closeLoadWindow(); void closeLoadWindow();
void cancelLoad(); void cancelLoad();
void appendSourceRow(const std::string& path); void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count
void removeSource(Gtk::ListBoxRow* row); void removeSource(Gtk::ListBoxRow* row);
Gtk::Widget& buildChainSection(); Gtk::Widget& buildChainSection();
@@ -139,6 +140,7 @@ private:
std::unique_ptr<Gtk::Window> loadWindow_; std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr; Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Expander sourcesExpander_;
Gtk::ListBox sourceList_; Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
+3 -1
View File
@@ -1,4 +1,6 @@
add_executable(effector_tests core_tests.cpp) # The CLI parser joins the core here: it is GUI-free too, and the canvas size
# it produces has to obey the same bounds a project file does.
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
target_link_libraries(effector_tests PRIVATE effector_core) target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
+268
View File
@@ -7,14 +7,19 @@
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <mutex> #include <mutex>
#include <optional>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "cli/options.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace { namespace {
@@ -43,6 +48,28 @@ effector::Image gradient(int width, int height, int shade) {
return image; return image;
} }
// A canvas taken from a project file, written by hand rather than by
// projectToJson, so a size no writer would produce can be fed to the reader.
std::string projectWithCanvas(const std::string& width, const std::string& height) {
return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height +
"},\n \"chain\": [{\"effect\": \"blur\"}]}";
}
// The canvas a `-s` value ends up as, or nothing when the parser refuses it.
std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
char program[] = "effector";
char flag[] = "-s";
std::vector<char> value(size.begin(), size.end());
value.push_back('\0');
char* argv[] = {program, flag, value.data()};
try {
const auto options = effector::parseOptions(3, argv);
return std::pair<int, int>{options.width, options.height};
} catch (const effector::EffectError&) {
return std::nullopt;
}
}
bool sameBytes(const effector::Image& a, const effector::Image& b) { bool sameBytes(const effector::Image& a, const effector::Image& b) {
if (a.width() != b.width() || a.height() != b.height()) return false; if (a.width() != b.width() || a.height() != b.height()) return false;
return std::equal(a.data(), a.data() + a.byteSize(), b.data()); return std::equal(a.data(), a.data() + a.byteSize(), b.data());
@@ -71,6 +98,45 @@ effector::Image renderBlur(const std::vector<effector::Image>& sources, int widt
return effect.render(sources, width, height, rng, merged); return effect.render(sources, width, height, rng, merged);
} }
effector::Image renderGrain(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::GrainEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
effector::Image renderMono(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::MonochromeEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
// A flat field of one colour, to read the channel weighting off directly.
effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
effector::Image image(width, height);
for (int y = 0; y < height; ++y) {
auto* row = image.row(y);
for (int x = 0; x < width; ++x) {
row[x * 3 + 0] = r;
row[x * 3 + 1] = g;
row[x * 3 + 2] = b;
}
}
return image;
}
// A flat field of one shade, to measure what the grain does to it.
effector::Image flat(int width, int height, std::uint8_t shade) {
effector::Image image(width, height);
std::fill(image.data(), image.data() + image.byteSize(), shade);
return image;
}
effector::Image render(const std::vector<effector::Image>& sources, int width, int height, effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) { std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect; effector::ScanlinesEffect effect;
@@ -202,6 +268,142 @@ int main() {
check(allBlack, "without sources blur renders an empty canvas"); check(allBlack, "without sources blur renders an empty canvas");
} }
std::cout << "grain\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderGrain(sources, 400, 300, 4, {});
const auto again = renderGrain(sources, 400, 300, 4, {});
const auto other = renderGrain(sources, 400, 300, 11, {});
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
check(!sameBytes(first, other), "a different seed changes the image");
// Mid grey is where the grain is strongest, so it must move, and stay
// grey while the channels are drawn together.
std::vector<Image> grey = {flat(200, 100, 128)};
const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}});
bool moved = false;
bool neutral = true;
for (int x = 0; x < 200; ++x) {
const auto* px = grained.row(50) + x * 3;
moved = moved || px[0] != 128;
neutral = neutral && px[0] == px[1] && px[1] == px[2];
}
check(moved, "grain moves a flat mid grey");
check(neutral, "monochrome grain keeps the channels equal");
const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}});
bool split = false;
for (int x = 0; x < 200; ++x) {
const auto* px = coloured.row(50) + x * 3;
split = split || px[0] != px[1] || px[1] != px[2];
}
check(split, "coloured grain pulls the channels apart");
// With the midtone weighting on, black and white are left alone; with
// it off the grain lands everywhere.
const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {});
bool untouched = true;
for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) {
untouched = untouched && blackWeighted.data()[i] == 0;
}
check(untouched, "the midtone weighting leaves black alone");
const auto blackFlat =
renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}});
bool lifted = false;
for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) {
lifted = lifted || blackFlat.data()[i] != 0;
}
check(lifted, "without the weighting grain reaches the shadows");
// Zero amount is a pass through, and a grain wider than the canvas must
// still only read the one cell it has.
check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}),
flat(32, 32, 128)),
"zero amount leaves the image untouched");
const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}});
check(coarse.width() == 40 && coarse.height() == 30,
"a grain wider than the canvas is clamped");
// Like blur, grain needs no sources: with none it grains the empty
// canvas it starts from instead of refusing to run.
const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}});
bool speckled = blank.width() == 100 && blank.height() == 100;
bool any = false;
for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0;
check(speckled && any, "without sources grain works on an empty canvas");
}
std::cout << "monochrome\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderMono(sources, 400, 300, 4, {});
const auto again = renderMono(sources, 400, 300, 4, {});
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
bool grey = true;
for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) {
grey = grey && first.data()[i] == first.data()[i + 1] &&
first.data()[i + 1] == first.data()[i + 2];
}
check(grey, "every pixel comes out neutral grey");
// A pure red field: the luminance weighting keeps little of it, the red
// weighting nearly all, and the blue weighting almost none.
std::vector<Image> red = {solid(64, 64, 255, 0, 0)};
const auto viaLuma = renderMono(red, 64, 64, 1, {});
const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}});
const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}});
check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red");
check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light");
check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark");
// Contrast works about mid grey: it leaves that alone and pushes the
// shades either side of it apart.
const auto midHard =
renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}});
check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey");
const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {});
const auto darkHard =
renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}});
check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows");
// The toners tint the greys, each in its own direction.
const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
{{"mono_tone", std::string("sepia")}});
check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2],
"sepia warms the greys");
const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
{{"mono_tone", std::string("cyanotype")}});
check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0],
"the cyanotype toner cools them");
// Strength mixes back towards the colour input: zero is a pass through,
// half sits between the two.
check(sameBytes(
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}),
solid(32, 32, 200, 40, 10)),
"zero strength leaves the colours untouched");
const auto half =
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}});
const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {});
check(half.data()[0] > full.data()[0] && half.data()[0] < 200,
"half strength sits between the grey and the colour");
// Like blur and grain it needs no sources: with none it converts the
// empty canvas, which stays black.
const auto blank = renderMono({}, 50, 50, 1, {});
bool allBlack = blank.width() == 50 && blank.height() == 50;
for (std::size_t i = 0; i < blank.byteSize(); ++i) {
allBlack = allBlack && blank.data()[i] == 0;
}
check(allBlack, "without sources monochrome renders an empty canvas");
}
std::cout << "chain\n"; std::cout << "chain\n";
{ {
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
@@ -345,6 +547,72 @@ int main() {
std::filesystem::remove_all(dir); std::filesystem::remove_all(dir);
} }
std::cout << "canvas size\n";
{
// The sizes the front-ends actually offer make the round trip whole.
int kept = 0;
for (const auto [w, h] : {std::pair<int, int>{1, 1},
{640, 400},
{1920, 1080},
{kMaxCanvasSide, kMaxCanvasSide}}) {
const auto back = projectFromJson(
projectWithCanvas(std::to_string(w), std::to_string(h)));
if (back.width == w && back.height == h) ++kept;
}
check(kept == 4, "every canvas in range is read back exactly");
Project project;
project.stages.push_back({effectByName("blur"), {}});
project.width = kMaxCanvasSide;
project.height = 3;
const auto written = projectFromJson(projectToJson(project));
check(written.width == kMaxCanvasSide && written.height == 3,
"the largest canvas survives a write and a read");
// A side that does not fit in an int must not be narrowed into one
// that does: 4294968296 truncates to a perfectly plausible 1000.
int refusals = 0;
for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0",
"20001", "800.5", "\"800\"", "true"}) {
try {
projectFromJson(projectWithCanvas(bad, "600"));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped");
refusals = 0;
for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) {
try {
projectFromJson(projectWithCanvas("800", bad));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 5, "the same bounds apply to the canvas height");
// A canvas the file does not mention keeps the defaults rather than
// failing: an old project is still readable.
const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}");
check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080");
// The CLI narrows the same way, from a long rather than a double.
const auto plain = canvasFromSize("800x600");
check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given");
const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" +
std::to_string(kMaxCanvasSide));
check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas");
check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int");
check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX");
check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long");
check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int");
check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"),
"-s refuses a side past the canvas limit");
check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"),
"-s refuses a side that is not positive");
}
std::cout << "image io\n"; std::cout << "image io\n";
{ {
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";