Offer standard web and print canvas formats

The canvas was two spin buttons, so "an Instagram story" or "A4 at 300 dpi"
was a number the user had to work out. src/core/canvas.cpp now holds one
table of standard sizes both front-ends read: a web format is a fixed pixel
size, a print format a sheet in millimetres that becomes pixels at a dpi.
Adding a format is a line in canvasFormats() — the dropdown, --format and
--help all walk the table.

The pixels stay the truth everywhere; renderChain never hears about formats.
A format is the presentation of a canvas, carried alongside the pixels in
Options and Project and empty for a custom size, and where one is named it
decides the pixels — so a project saved as A4 reopens as A4 rather than as
whatever it happened to measure.

In the interface a format fills the width and height in and greys them out
rather than hiding them: the size a sheet works out to is worth seeing. A
print format also shows a DPI row, and Custom hands the spins back keeping
the last size, which makes starting from a format and then tweaking it the
ordinary path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 22:34:17 +02:00
co-authored by Claude Opus 5
parent f4d8457828
commit d48a81c665
12 changed files with 494 additions and 24 deletions
+11
View File
@@ -80,6 +80,17 @@ CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — (`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
two effects must not declare the same param key. two effects must not declare the same param key.
### The canvas is a format or plain pixels
`src/core/canvas.cpp` holds the one table of standard sizes both front-ends
offer: web formats are a fixed pixel size, print formats a sheet in millimetres
that becomes pixels at a dpi. Adding a format is a line in `canvasFormats()` —
the dropdown, `--format` and `--help` all walk that table. The pixels stay the
truth everywhere (`renderChain` never hears about formats); a format is the
*presentation* of a canvas, carried alongside the pixels in `Options` and
`Project` and empty for a custom size. Where a format is named it decides the
pixels, so a project saved as A4 reopens as A4.
### Projects are plain JSON ### Projects are plain JSON
`src/core/project.cpp` reads and writes the whole setup — source paths, the `src/core/project.cpp` reads and writes the whole setup — source paths, the
+1
View File
@@ -23,6 +23,7 @@ find_package(Threads REQUIRED)
# --- core: effects and image handling, no GUI --------------------------------- # --- core: effects and image handling, no GUI ---------------------------------
add_library(effector_core STATIC add_library(effector_core STATIC
src/core/canvas.cpp
src/core/chain.cpp src/core/chain.cpp
src/core/effect.cpp src/core/effect.cpp
src/core/image.cpp src/core/image.cpp
+16
View File
@@ -34,6 +34,22 @@ build/effector -o out.png -s 1920x1080 \
--strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png --strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png
``` ```
The canvas is either a size in pixels (`-s 1920x1080`, the Custom entry in the
interface) or one of the standard formats, which `--help` lists in full:
```bash
build/effector -o post.png --format instagram-post -e tiles
build/effector -o sheet.png --format a4 --dpi 300 -e scanlines
```
A web format — the Instagram sizes, Full HD, 4K UHD — is a fixed pixel size. A
print format — A5, A4, A3, US Letter — is a sheet of paper, and becomes pixels
at `--dpi` (36–1200, 300 by default), so A4 at 300 dpi is 2480×3508. Picking a
format in the interface fills the width and height in and greys them out;
Custom hands them back, keeping whatever the format last worked out to. A
format is saved with the project, so an A4 project reopens as A4 rather than as
the pixels it happened to be.
Any option can also be passed when launching the interface; it becomes the Any option can also be passed when launching the interface; it becomes the
starting state (images, canvas size, effect settings, seed). starting state (images, canvas size, effect settings, seed).
+31
View File
@@ -5,6 +5,7 @@
#include <cstring> #include <cstring>
#include <sstream> #include <sstream>
#include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
@@ -74,6 +75,20 @@ Options parseOptions(int argc, char** argv) {
"each side between " + std::to_string(kMinCanvasSide) + " and " + "each side between " + std::to_string(kMinCanvasSide) + " and " +
std::to_string(kMaxCanvasSide)); std::to_string(kMaxCanvasSide));
} }
} else if (arg == "--format") {
const std::string value = requireValue(argc, argv, i, arg);
if (canvasFormatByKey(value) == nullptr) {
throw EffectError("unknown canvas format '" + value + "', see --help");
}
options.format = value;
} else if (arg == "--dpi") {
const std::string value = requireValue(argc, argv, i, arg);
long dpi = 0;
if (!parseLong(value, dpi) || dpi < kMinDpi || dpi > kMaxDpi) {
throw EffectError("invalid dpi '" + value + "', expected " +
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
}
options.dpi = static_cast<int>(dpi);
} 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);
long seed = 0; long seed = 0;
@@ -140,6 +155,11 @@ Options parseOptions(int argc, char** argv) {
options.images.push_back(arg); options.images.push_back(arg);
} }
} }
// Applied once everything is parsed, so --dpi may come before --format and
// so a format always wins over an -s given alongside it.
if (const auto* format = canvasFormatByKey(options.format)) {
canvasFormatSize(*format, options.dpi, options.width, options.height);
}
if (options.effects.empty()) options.effects.push_back(defaultEffectName()); if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options; return options;
} }
@@ -153,10 +173,21 @@ std::string usage(const char* programName) {
<< " -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, each side " << kMinCanvasSide << "-" << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
<< kMaxCanvasSide << " (default: 1920x1080)\n" << kMaxCanvasSide << " (default: 1920x1080)\n"
<< " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\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";
out << "\nCanvas formats (--format):\n";
for (const auto& format : canvasFormats()) {
out << " " << format.key;
if (format.key.size() < 20) out << std::string(20 - format.key.size(), ' ');
out << " " << format.label << ", " << canvasFormatDetail(format)
<< (format.print ? " at --dpi" : "") << "\n";
}
for (const auto& effect : effects()) { for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n"; out << "\n" << effect->title() << " options:\n";
for (const auto& spec : effect->params()) { for (const auto& spec : effect->params()) {
+3
View File
@@ -5,6 +5,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
@@ -16,6 +17,8 @@ struct Options {
std::string output; // empty -> launch the interface std::string output; // empty -> launch the interface
int width = 1920; int width = 1920;
int height = 1080; int height = 1080;
std::string format; // a named canvas format, empty for plain pixels
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
std::optional<std::uint64_t> seed; std::optional<std::uint64_t> seed;
ParamValues params; // only the keys the user actually passed ParamValues params; // only the keys the user actually passed
bool listEffects = false; bool listEffects = false;
+76
View File
@@ -0,0 +1,76 @@
#include "core/canvas.hpp"
#include <algorithm>
#include <cmath>
#include <locale>
#include <sstream>
#include "core/chain.hpp"
namespace effector {
namespace {
// 210 mm reads as "210", 215.9 mm as "215.9". In the C locale whatever the
// user's is: gtkmm sets the global locale from the environment, so a stream
// left to its default writes "210,0" on a German desktop.
std::string millimetres(double mm) {
std::ostringstream out;
out.imbue(std::locale::classic());
out.setf(std::ios::fixed);
out.precision(1);
out << mm;
std::string text = out.str();
if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2);
return text;
}
} // namespace
const std::vector<CanvasFormat>& canvasFormats() {
// Written out once and handed back by reference: the table is fixed, and
// both front-ends walk it for their menu and their --help.
static const std::vector<CanvasFormat> formats = {
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
{"a5", "A5", true, 0, 0, 148.0, 210.0},
{"a4", "A4", true, 0, 0, 210.0, 297.0},
{"a3", "A3", true, 0, 0, 297.0, 420.0},
{"letter", "US Letter", true, 0, 0, 215.9, 279.4},
};
return formats;
}
const CanvasFormat* canvasFormatByKey(const std::string& key) {
if (key.empty()) return nullptr;
for (const auto& format : canvasFormats()) {
if (format.key == key) return &format;
}
return nullptr;
}
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height) {
if (!format.print) {
width = format.width;
height = format.height;
return;
}
const int clamped = std::clamp(dpi, kMinDpi, kMaxDpi);
const auto pixels = [clamped](double mm) {
const long value = std::lround(mm / 25.4 * clamped);
return static_cast<int>(std::clamp<long>(value, kMinCanvasSide, kMaxCanvasSide));
};
width = pixels(format.widthMm);
height = pixels(format.heightMm);
}
std::string canvasFormatDetail(const CanvasFormat& format) {
if (format.print) {
return millimetres(format.widthMm) + " × " + millimetres(format.heightMm) + " mm";
}
return std::to_string(format.width) + " × " + std::to_string(format.height) + " px";
}
} // namespace effector
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
namespace effector {
// A named canvas size both front-ends offer, so "A4 at 300 dpi" is one choice
// rather than a pixel count the user has to work out. Web formats are a fixed
// pixel size; print formats are a physical size in millimetres and only become
// pixels once a resolution is given, which is why the dpi belongs to the
// canvas rather than to any one format.
struct CanvasFormat {
std::string key; // written in a project and given to --format, e.g. "a4"
std::string label; // shown in the interface, e.g. "A4"
bool print = false;
int width = 0; // pixels, web formats only
int height = 0; //
double widthMm = 0.0; // millimetres, print formats only
double heightMm = 0.0;
};
// The dpi a print format is rendered at. The bounds keep A3 — the largest
// sheet here — inside kMaxCanvasSide at the top end, and keep a sheet from
// coming out too small to see at the bottom.
inline constexpr int kMinDpi = 36;
inline constexpr int kMaxDpi = 1200;
inline constexpr int kDefaultDpi = 300;
// In the order they are offered: the web formats first, then the paper sizes.
const std::vector<CanvasFormat>& canvasFormats();
// Null when the key names no format, which is how a custom canvas — the user's
// own pixel sizes — is spelled everywhere: an empty or unknown key.
const CanvasFormat* canvasFormatByKey(const std::string& key);
// The pixel size of a format. `dpi` is ignored by a web format, which already
// is a pixel size; a print format is rounded to the nearest pixel and clamped
// to the canvas bounds.
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height);
// "1080 × 1080 px" or "210 × 297 mm", for the dropdown and for --help.
std::string canvasFormatDetail(const CanvasFormat& format);
} // namespace effector
+34 -2
View File
@@ -10,6 +10,7 @@
#include <locale> #include <locale>
#include <sstream> #include <sstream>
#include "core/canvas.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
@@ -306,6 +307,18 @@ int canvasSide(const Json& value, const char* what) {
return static_cast<int>(value.number); return static_cast<int>(value.number);
} }
// Checked as a double before narrowing, exactly as a canvas side is.
int canvasDpi(const Json& value) {
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
throw EffectError("the canvas dpi must be a whole number");
}
if (!(value.number >= kMinDpi) || !(value.number <= kMaxDpi)) {
throw EffectError("the canvas dpi must be between " + std::to_string(kMinDpi) + " and " +
std::to_string(kMaxDpi));
}
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;
@@ -323,8 +336,15 @@ std::string projectToJson(const Project& project, const std::string& directory)
out.imbue(std::locale::classic()); out.imbue(std::locale::classic());
out << "{\n"; out << "{\n";
out << " \"effector_project\": 1,\n"; out << " \"effector_project\": 1,\n";
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height;
<< "},\n"; // The pixels are always written, so a project opens the same in a reader
// that knows nothing of formats; the format and its dpi come after them
// and only when there is one to name.
if (const auto* format = canvasFormatByKey(project.format)) {
out << ", \"format\": " << quote(format->key);
if (format->print) out << ", \"dpi\": " << project.dpi;
}
out << "},\n";
if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
out << " \"sources\": ["; out << " \"sources\": [";
@@ -396,6 +416,18 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
if (const auto* height = canvas->member("height")) { if (const auto* height = canvas->member("height")) {
project.height = canvasSide(*height, "height"); project.height = canvasSide(*height, "height");
} }
if (const auto* dpi = canvas->member("dpi")) project.dpi = canvasDpi(*dpi);
// A format the build does not know is simply a custom canvas: the
// pixels were written too, so the project still renders as it was
// saved. Where the format is known it decides the pixels, so a
// hand-edited dpi takes effect and a preset stays true to its name.
if (const auto* name = canvas->member("format"); name != nullptr &&
name->kind == Json::Kind::String) {
if (const auto* format = canvasFormatByKey(name->text)) {
project.format = format->key;
canvasFormatSize(*format, project.dpi, project.width, project.height);
}
}
} }
if (const auto* seed = root.member("seed")) { if (const auto* seed = root.member("seed")) {
+7
View File
@@ -5,6 +5,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
@@ -18,6 +19,12 @@ struct Project {
std::vector<ChainStage> stages; std::vector<ChainStage> stages;
int width = 1920; int width = 1920;
int height = 1080; int height = 1080;
// The named format the canvas came from, empty for a custom pixel size.
// The pixels above stay the truth for the render; this is what the
// interface puts back in its dropdown, and what a print format needs its
// resolution for.
std::string format;
int dpi = kDefaultDpi;
std::optional<std::uint64_t> seed; std::optional<std::uint64_t> seed;
}; };
+122 -22
View File
@@ -7,6 +7,7 @@
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include "core/canvas.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"
@@ -175,28 +176,7 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
sidebar->append(sourcesExpander_); sidebar->append(sourcesExpander_);
sidebar->append(*sectionLabel("Canvas")); sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>(); sidebar->append(buildCanvasSection());
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->set_hexpand(true);
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
}
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
widthLabel->set_xalign(0.0F);
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
heightLabel->set_xalign(0.0F);
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid);
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");
@@ -220,6 +200,122 @@ void EffectorWindow::updateSourcesLabel() {
: "Source images (" + std::to_string(count) + ")"); : "Source images (" + std::to_string(count) + ")");
} }
// The canvas is either a named format — a web size, or a sheet of paper at a
// resolution — or the user's own pixels. A format owns the two spin buttons
// while it is picked, which is why they are shown greyed out rather than
// hidden: the size a format works out to is worth seeing.
Gtk::Widget& EffectorWindow::buildCanvasSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
// Index 0 is "Custom"; every later index is canvasFormats()[index - 1].
std::vector<Glib::ustring> labels{"Custom"};
for (const auto& format : canvasFormats()) {
labels.push_back(format.label + " (" + canvasFormatDetail(format) + ")");
}
formatDrop_.set_model(Gtk::StringList::create(labels));
formatDrop_.set_selected(0);
formatDrop_.set_tooltip_text("Standard web and print sizes, or your own pixels");
formatDrop_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::syncCanvasWidgets));
box->append(formatDrop_);
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080);
dpiSpin_.set_range(kMinDpi, kMaxDpi);
dpiSpin_.set_increments(1, 50);
dpiSpin_.set_value(kDefaultDpi);
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
spin->set_hexpand(true);
}
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->signal_value_changed().connect([this] {
if (!suspendCanvasSync_) onParamChanged();
});
}
// The dpi decides the pixels of a sheet, so moving it goes through the
// same path a format change does.
dpiSpin_.signal_value_changed().connect([this] {
if (!suspendCanvasSync_) syncCanvasWidgets();
});
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
widthLabel->set_xalign(0.0F);
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
heightLabel->set_xalign(0.0F);
auto* dpiLabel = Gtk::make_managed<Gtk::Label>("DPI");
dpiLabel->set_xalign(0.0F);
dpiLabel_ = dpiLabel;
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
grid->attach(*dpiLabel, 0, 2);
grid->attach(dpiSpin_, 1, 2);
box->append(*grid);
// Quietly: the sidebar is still being built, and nothing is to be rendered
// until the initial options or a project have had their say.
suspendCanvasSync_ = true;
syncCanvasWidgets();
suspendCanvasSync_ = false;
return *box;
}
std::string EffectorWindow::canvasFormatKey() const {
const auto selected = formatDrop_.get_selected();
if (selected == GTK_INVALID_LIST_POSITION || selected == 0) return {};
const auto index = static_cast<std::size_t>(selected) - 1;
const auto& formats = canvasFormats();
return index < formats.size() ? formats[index].key : std::string();
}
// A key the build does not know — an old project, or a hand-edited file — lands
// on "Custom", which leaves the pixels the project carries alone.
void EffectorWindow::setCanvasFormat(const std::string& key, int dpi) {
const bool suspended = suspendCanvasSync_;
suspendCanvasSync_ = true;
dpiSpin_.set_value(std::clamp(dpi, kMinDpi, kMaxDpi));
guint selected = 0;
const auto& formats = canvasFormats();
for (std::size_t i = 0; i < formats.size(); ++i) {
if (formats[i].key == key) selected = static_cast<guint>(i + 1);
}
formatDrop_.set_selected(selected);
syncCanvasWidgets(); // still suspended, so the spins follow without a render
suspendCanvasSync_ = suspended;
}
void EffectorWindow::syncCanvasWidgets() {
// Suspended means a caller is putting the canvas together — the initial
// options, or a project being opened — and will ask for the render itself.
const bool quiet = suspendCanvasSync_;
const auto* format = canvasFormatByKey(canvasFormatKey());
const bool print = format != nullptr && format->print;
dpiLabel_->set_visible(print);
dpiSpin_.set_visible(print);
widthSpin_.set_sensitive(format == nullptr);
heightSpin_.set_sensitive(format == nullptr);
if (format != nullptr) {
int width = 0;
int height = 0;
canvasFormatSize(*format, static_cast<int>(dpiSpin_.get_value()), width, height);
const bool suspended = suspendCanvasSync_;
suspendCanvasSync_ = true; // one render for the change, not one per spin
widthSpin_.set_value(width);
heightSpin_.set_value(height);
suspendCanvasSync_ = suspended;
}
if (!quiet) onParamChanged();
}
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);
@@ -429,6 +525,7 @@ void EffectorWindow::applyInitial(const Options& initial) {
widthSpin_.set_value(initial.width); widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height); heightSpin_.set_value(initial.height);
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
if (initial.seed) { if (initial.seed) {
seed_ = *initial.seed; seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
@@ -1098,6 +1195,8 @@ Project EffectorWindow::currentProject() const {
project.stages = stages_; project.stages = stages_;
project.width = static_cast<int>(widthSpin_.get_value()); project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value()); project.height = static_cast<int>(heightSpin_.get_value());
project.format = canvasFormatKey();
project.dpi = static_cast<int>(dpiSpin_.get_value());
project.seed = seed_; project.seed = seed_;
return project; return project;
} }
@@ -1114,6 +1213,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
widthSpin_.set_value(project.width); widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height); heightSpin_.set_value(project.height);
setCanvasFormat(project.format, project.dpi);
seed_ = project.seed.value_or(Rng::randomSeed()); seed_ = project.seed.value_or(Rng::randomSeed());
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
+14
View File
@@ -49,6 +49,14 @@ private:
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 updateSourcesLabel(); // the collapsed expander shows the count
Gtk::Widget& buildCanvasSection();
// "" while "Custom" is picked, otherwise the format's key.
std::string canvasFormatKey() const;
void setCanvasFormat(const std::string& key, int dpi); // dropdown and dpi
// A format owns the pixel sizes, so the spins follow it and are greyed
// out; "Custom" hands them back to the user untouched.
void syncCanvasWidgets();
void removeSource(Gtk::ListBoxRow* row); void removeSource(Gtk::ListBoxRow* row);
Gtk::Widget& buildChainSection(); Gtk::Widget& buildChainSection();
@@ -146,8 +154,14 @@ private:
Gtk::Label* loadFileLabel_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Expander sourcesExpander_; Gtk::Expander sourcesExpander_;
Gtk::ListBox sourceList_; Gtk::ListBox sourceList_;
Gtk::DropDown formatDrop_;
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
Gtk::SpinButton dpiSpin_;
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
// Set while a format fills the spins, so their own handler does not queue
// a second render on top of the one the format change asks for.
bool suspendCanvasSync_ = false;
Gtk::Grid paramGrid_; Gtk::Grid paramGrid_;
Gtk::Label paramHeading_; Gtk::Label paramHeading_;
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
+134
View File
@@ -16,6 +16,7 @@
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/canvas.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"
@@ -76,6 +77,25 @@ std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
} }
} }
// The options a whole command line ends up as, or nothing when the parser
// refuses it.
std::optional<effector::Options> parsedOptions(const std::vector<std::string>& args) {
std::vector<std::vector<char>> storage;
storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'});
for (const auto& arg : args) {
std::vector<char> buffer(arg.begin(), arg.end());
buffer.push_back('\0');
storage.push_back(std::move(buffer));
}
std::vector<char*> argv;
for (auto& buffer : storage) argv.push_back(buffer.data());
try {
return effector::parseOptions(static_cast<int>(argv.size()), argv.data());
} 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());
@@ -1062,6 +1082,120 @@ int main() {
"-s refuses a side that is not positive"); "-s refuses a side that is not positive");
} }
std::cout << "canvas formats\n";
{
using effector::canvasFormatByKey;
using effector::canvasFormats;
using effector::canvasFormatSize;
// Every format the menu offers must land inside the canvas bounds at
// every resolution it can be asked for, or the dropdown would offer a
// size the renderer refuses.
int usable = 0;
for (const auto& format : canvasFormats()) {
int width = 0;
int height = 0;
bool ok = true;
for (const int dpi : {effector::kMinDpi, effector::kDefaultDpi, effector::kMaxDpi}) {
canvasFormatSize(format, dpi, width, height);
ok = ok && width >= kMinCanvasSide && width <= kMaxCanvasSide &&
height >= kMinCanvasSide && height <= kMaxCanvasSide;
}
if (ok) ++usable;
}
check(usable == static_cast<int>(canvasFormats().size()),
"every format stays inside the canvas bounds at every dpi");
int unique = 0;
for (const auto& format : canvasFormats()) {
if (canvasFormatByKey(format.key) == &format) ++unique;
}
check(unique == static_cast<int>(canvasFormats().size()), "format keys are unique");
check(canvasFormatByKey("") == nullptr && canvasFormatByKey("a6") == nullptr,
"an empty or unknown key is a custom canvas");
int width = 0;
int height = 0;
canvasFormatSize(*canvasFormatByKey("a4"), 300, width, height);
check(width == 2480 && height == 3508, "A4 at 300 dpi is 2480x3508");
canvasFormatSize(*canvasFormatByKey("a4"), 600, width, height);
check(width == 4961 && height == 7016, "doubling the dpi doubles the sheet");
canvasFormatSize(*canvasFormatByKey("instagram-story"), 600, width, height);
check(width == 1080 && height == 1920, "a web format ignores the dpi");
// The CLI: a format decides the canvas whichever side of -s it is on,
// and --dpi may come before the format it applies to.
const auto sheet = parsedOptions({"--format", "a4", "--dpi", "600"});
check(sheet && sheet->width == 4961 && sheet->height == 7016,
"--format a4 --dpi 600 sets the canvas");
const auto reordered = parsedOptions({"--dpi", "600", "--format", "a4"});
check(reordered && reordered->width == 4961, "--dpi may be given before --format");
const auto both = parsedOptions({"-s", "800x600", "--format", "instagram-post"});
check(both && both->width == 1080 && both->height == 1080, "--format overrides -s");
const auto custom = parsedOptions({"-s", "800x600"});
check(custom && custom->format.empty() && custom->width == 800,
"-s alone leaves the canvas custom");
check(!parsedOptions({"--format", "a6"}), "--format refuses an unknown format");
check(!parsedOptions({"--dpi", "0"}) && !parsedOptions({"--dpi", "5000"}) &&
!parsedOptions({"--dpi", "many"}),
"--dpi refuses a resolution outside the bounds");
// A project keeps the format, and the format keeps the pixels: a dpi
// edited by hand moves the canvas with it.
Project sheetProject;
sheetProject.stages.push_back({effectByName("blur"), {}});
sheetProject.format = "a4";
sheetProject.dpi = 600;
canvasFormatSize(*canvasFormatByKey("a4"), 600, sheetProject.width, sheetProject.height);
const auto backProject = projectFromJson(projectToJson(sheetProject));
check(backProject.format == "a4" && backProject.dpi == 600 && backProject.width == 4961,
"a print format and its dpi survive a round trip");
const auto retuned = projectFromJson(
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a4\", "
"\"dpi\": 300},\n \"chain\": [{\"effect\": \"blur\"}]}");
check(retuned.width == 2480 && retuned.height == 3508,
"a named format decides the pixels over whatever was written beside it");
// A format this build does not know — a newer file, or a typo — is a
// custom canvas, so the pixels the file carries are still rendered.
const auto unknown = projectFromJson(
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a6\"},\n"
" \"chain\": [{\"effect\": \"blur\"}]}");
check(unknown.format.empty() && unknown.width == 800 && unknown.height == 600,
"an unknown format falls back to the canvas the file names");
Project plainProject;
plainProject.stages.push_back({effectByName("blur"), {}});
plainProject.width = 800;
plainProject.height = 600;
const auto plainText = projectToJson(plainProject);
check(plainText.find("format") == std::string::npos &&
plainText.find("dpi") == std::string::npos,
"a custom canvas writes no format and no dpi");
const auto webText = projectToJson([] {
Project web;
web.stages.push_back({effectByName("blur"), {}});
web.format = "hd";
return web;
}());
check(webText.find("\"format\": \"hd\"") != std::string::npos &&
webText.find("dpi") == std::string::npos,
"a web format writes no dpi it would never use");
int badDpi = 0;
for (const char* bad : {"0", "5000", "300.5", "\"300\""}) {
try {
projectFromJson(std::string("{\"canvas\": {\"dpi\": ") + bad +
"},\n \"chain\": [{\"effect\": \"blur\"}]}");
} catch (const EffectError&) {
++badDpi;
}
}
check(badDpi == 4, "a dpi outside the bounds is refused");
}
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";