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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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")) {
|
||||||
|
|||||||
@@ -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
@@ -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_));
|
||||||
|
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user