Merge branch 'empty-chain-start'

This commit is contained in:
Si
2026-09-19 22:03:31 +02:00
20 changed files with 1098 additions and 164 deletions
+42 -5
View File
@@ -53,6 +53,17 @@ travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
A param can also be marked `.styled()`, which says a *named style* carries it
rather than the stage it sits on: the font and the colour of a text stage, but
never its words or its placement. The pool a style comes from is named by
`Effect::styleKind()` (empty by default — the effect takes no style; `"text"`
for the text effect), and effects answering the same kind share one pool, so a
second text-ish effect would slot into the styles that already exist. The
front-ends know nothing more than that: the interface builds the style row and
the project file writes a style's params from `styleSpecs(kind)`
(`src/core/style.cpp`), which is to a style exactly what `params()` is to a
stage.
Whether an effect works from the loaded photographs is part of the same Whether an effect works from the loaded photographs is part of the same
contract: `Effect::needsSources()` (default true, false for blur). Only the contract: `Effect::needsSources()` (default true, false for blur). Only the
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`) first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
@@ -73,10 +84,32 @@ the whole of the cascade — an effect neither knows nor cares whether its input
came from disk. All stages share one `Rng` and run strictly in sequence, so a came from disk. All stages share one `Rng` and run strictly in sequence, so a
seed still reproduces the cascade byte for byte. seed still reproduces the cascade byte for byte.
A stage carries a `ChainStage::enabled` flag, and `enabledStages`
(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is
skipped whole — handed no image and drawing nothing from the shared rng — so
switching one off renders exactly what the chain rendered before it was added.
`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through
it, which is why the status line and the file name never mention a stage that
did not run. A chain with every stage disabled throws rather than rendering
nothing.
### A style is a look several stages share
`ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is
the one place that resolves one: an effect's defaults, then the stage's own
values, then the style's values for the params it carries. A stage always keeps
a full set of values of its own beside the style, so switching it to "Custom",
dropping the style, or opening the project in a build that does not know the
style renders exactly the same picture — a style moves stages together, it
never holds the only copy of anything. The interface writes an edited param to
both the style and the stage for that reason. `renderChain` takes the pool as a
trailing argument; the command line builds no styles and passes none.
The interface builds the chain in the sidebar ("Effect chain": add from the The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, one stage selected at a time with its params below), the menu, reorder, remove, switch off with the check on the row, one stage selected
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur at a time with its params below), the CLI builds it from repeated `-e`:
--radius 5`. Flags share one namespace across all effects, so `buildChain` `-e scanlines --strip-height 6 -e blur --radius 5`, with `--disable NAME`
leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain`
(`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.
@@ -94,11 +127,15 @@ 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
chain with its params, canvas and seed — as a small JSON file, with its own named styles, the chain with its params (and its `"enabled": false` and
`"style"`, each written only when there is something to say), canvas and seed — as a small JSON file, with its own
minimal reader rather than a JSON dependency. Writing is driven by the effects' minimal reader rather than a JSON dependency. Writing is driven by the effects'
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a `ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
key an effect does not declare is never written and never read back: an old key an effect does not declare is never written and never read back: an old
project keeps the defaults for params that did not exist when it was saved. project keeps the defaults for params that did not exist when it was saved. The
same leniency covers styles: one of a kind this build does not know, one with
no name or a repeated one, and a stage naming a style the file does not hold
are each dropped on their own rather than refusing the project.
Source paths under the project's own folder are written relative, so a folder Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole. can be moved or shared whole.
+1
View File
@@ -31,6 +31,7 @@ add_library(effector_core STATIC
src/core/project.cpp src/core/project.cpp
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/core/style.cpp
src/effects/blur.cpp src/effects/blur.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
+10 -2
View File
@@ -1,5 +1,6 @@
#include "cli/options.hpp" #include "cli/options.hpp"
#include <algorithm>
#include <charconv> #include <charconv>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
@@ -89,6 +90,10 @@ Options parseOptions(int argc, char** argv) {
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
} }
options.dpi = static_cast<int>(dpi); options.dpi = static_cast<int>(dpi);
} else if (arg == "--disable") {
const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, exactly as -e does
options.disabled.push_back(name);
} 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;
@@ -160,7 +165,6 @@ Options parseOptions(int argc, char** argv) {
if (const auto* format = canvasFormatByKey(options.format)) { if (const auto* format = canvasFormatByKey(options.format)) {
canvasFormatSize(*format, options.dpi, options.width, options.height); canvasFormatSize(*format, options.dpi, options.width, options.height);
} }
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options; return options;
} }
@@ -169,13 +173,15 @@ std::string usage(const char* programName) {
out << "Usage: " << programName << " [options] [image ...]\n\n" out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n" << "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "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 (--output"
" defaults to " << 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, 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" << " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi << " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\n" << " (default: " << kDefaultDpi << ")\n"
<< " --disable NAME keep this effect in the chain but skip it\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";
@@ -207,6 +213,8 @@ std::vector<ChainStage> buildChain(const Options& options) {
ChainStage stage; ChainStage stage;
stage.effect = effectByName(name); stage.effect = effectByName(name);
stage.values = stage.effect->defaults(); stage.values = stage.effect->defaults();
stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) ==
options.disabled.end();
for (const auto& spec : stage.effect->params()) { for (const auto& spec : stage.effect->params()) {
const auto it = options.params.find(spec.key); const auto it = options.params.find(spec.key);
if (it != options.params.end()) stage.values[spec.key] = it->second; if (it != options.params.end()) stage.values[spec.key] = it->second;
+6 -1
View File
@@ -14,6 +14,10 @@ namespace effector {
struct Options { struct Options {
std::vector<std::string> images; std::vector<std::string> images;
std::vector<std::string> effects; // one per cascade stage, in order std::vector<std::string> effects; // one per cascade stage, in order
// The effects named by --disable: their stages stay in the chain, in
// place, but are skipped. Named rather than numbered, exactly as the
// params are, so two stages of one effect are disabled together.
std::vector<std::string> disabled;
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;
@@ -33,7 +37,8 @@ std::string usage(const char* programName);
// Turns the parsed names and flags into cascade stages: every flag goes to the // Turns the parsed names and flags into cascade stages: every flag goes to the
// stage whose effect declares it, so --strip-height and --radius can be given // stage whose effect declares it, so --strip-height and --radius can be given
// together for `-e scanlines -e blur`. // together for `-e scanlines -e blur`. A stage named by --disable is built
// with all its settings and marked to be skipped.
std::vector<ChainStage> buildChain(const Options& options); std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector } // namespace effector
+47 -14
View File
@@ -2,17 +2,51 @@
namespace effector { namespace effector {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> running;
running.reserve(stages.size());
for (const auto& stage : stages) {
if (stage.enabled && stage.effect) running.push_back(&stage);
}
return running;
}
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
ParamValues values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
const Style* style = findStyle(styles, stage.effect->styleKind(), stage.style);
if (style != nullptr) {
// Only the params the effect marked styled(): the words and the
// placement stay the stage's own, which is what lets two stages share
// one look without sharing a position.
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) values[spec.key] = found->second;
}
}
return values;
}
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources, Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng) { int width, int height, Rng& rng, const StyleSet& styles) {
if (stages.empty()) throw EffectError("The effect chain is empty."); if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
}
// A disabled stage never reaches the render at all: it is not handed an
// image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added.
const auto running = enabledStages(stages);
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
Image image; Image image;
for (std::size_t i = 0; i < stages.size(); ++i) { for (std::size_t i = 0; i < running.size(); ++i) {
const auto& stage = stages[i]; const auto& stage = *running[i];
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
auto values = stage.effect->defaults(); const auto values = stageValues(stage, styles);
for (const auto& [key, value] : stage.values) values[key] = value;
if (i == 0) { if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values); image = stage.effect->render(sources, width, height, rng, values);
@@ -27,26 +61,25 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
} }
bool chainNeedsSources(const std::vector<ChainStage>& stages) { bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false; const auto running = enabledStages(stages);
return stages.front().effect->needsSources(); if (running.empty()) return false;
return running.front()->effect->needsSources();
} }
std::string chainTitle(const std::vector<ChainStage>& stages) { std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text; std::string text;
for (const auto& stage : stages) { for (const auto* stage : enabledStages(stages)) {
if (!stage.effect) continue;
if (!text.empty()) text += " → "; if (!text.empty()) text += " → ";
text += stage.effect->title(); text += stage->effect->title();
} }
return text; return text;
} }
std::string chainSlug(const std::vector<ChainStage>& stages) { std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string text; std::string text;
for (const auto& stage : stages) { for (const auto* stage : enabledStages(stages)) {
if (!stage.effect) continue;
if (!text.empty()) text += "-"; if (!text.empty()) text += "-";
text += stage.effect->name(); text += stage->effect->name();
} }
return text; return text;
} }
+33 -9
View File
@@ -7,6 +7,7 @@
#include "core/image.hpp" #include "core/image.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -21,23 +22,46 @@ inline constexpr int kMaxCanvasSide = 20000;
struct ChainStage { struct ChainStage {
EffectPtr effect; EffectPtr effect;
ParamValues values; // missing keys fall back to the effect's defaults ParamValues values; // missing keys fall back to the effect's defaults
// A disabled stage is skipped whole, keeping its settings and its place in
// the chain, so a stage can be switched off and back on while trying
// things out without being taken apart and rebuilt.
bool enabled = true;
// The style this stage follows, by name, in the pool its effect names with
// styleKind(); empty when the stage stands on its own. A stage keeps a
// full set of values either way, so a style that is dropped or renamed
// away leaves the stage rendering exactly what it rendered before.
std::string style = {};
}; };
// Renders the stages in order. The first one sees the loaded sources; every // The values a stage actually runs with: its effect's defaults, then the
// later one sees the previous stage's output as its only source, so blur after // stage's own values, then the values of the style it follows for the params
// scanlines softens the scanlines rather than the photographs. // that style carries. The one place a style is resolved.
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
// The stages that actually run, in order, as pointers into `stages`. A
// disabled stage draws nothing from the rng and is handed no image, so a chain
// renders exactly as it would if that stage had never been added -- this is
// the one place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
// Renders the enabled stages in order. The first one sees the loaded sources;
// every later one sees the previous stage's output as its only source, so blur
// after scanlines softens the scanlines rather than the photographs.
// //
// All stages share one Rng and run strictly in sequence, so a seed still // All stages share one Rng and run strictly in sequence, so a seed still
// reproduces the cascade byte for byte. // reproduces the cascade byte for byte. Throws when no stage is left to run.
// `styles` is the project's pool; a chain built without one -- the command
// line's -- passes none and renders from the stages alone.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources, Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng); int width, int height, Rng& rng, const StyleSet& styles = {});
// Whether the chain has to be given source images. Only the first stage ever // Whether the chain has to be given source images. Only the first stage that
// sees them — every later one works on the stage before it — so this is simply // runs ever sees them — every later one works on the stage before it — so this
// whether the first effect declares that it needs them. // is simply whether that effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages); bool chainNeedsSources(const std::vector<ChainStage>& stages);
// "Scanlines → Blur", for window titles and status lines. // "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages); std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names. // "scanlines-blur", for file names.
+1 -19
View File
@@ -4,25 +4,7 @@ namespace effector {
ParamValues Effect::defaults() const { ParamValues Effect::defaults() const {
ParamValues values; ParamValues values;
for (const auto& spec : params()) { for (const auto& spec : params()) values[spec.key] = paramDefault(spec);
switch (spec.type) {
case ParamSpec::Type::Int:
values[spec.key] = static_cast<long>(spec.defaultValue);
break;
case ParamSpec::Type::Double:
values[spec.key] = spec.defaultValue;
break;
case ParamSpec::Type::Bool:
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
values[spec.key] = spec.defaultChoice;
break;
}
}
return values; return values;
} }
+6
View File
@@ -32,6 +32,12 @@ public:
// chain that starts with one of them needs no sources at all. // chain that starts with one of them needs no sources at all.
virtual bool needsSources() const { return true; } virtual bool needsSources() const { return true; }
// The pool of named styles this effect's styled params are drawn from, or
// empty when it takes no style. Effects that answer the same kind share
// one pool, so a stage can be pointed at a look its neighbours already
// use. An effect with no kind must declare no styled params.
virtual std::string styleKind() const { return {}; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
+23
View File
@@ -10,6 +10,12 @@ std::string ParamSpec::flag() const {
return flag; return flag;
} }
ParamSpec ParamSpec::styled() const {
ParamSpec copy = *this;
copy.inStyle = true;
return copy;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) { long maximum, std::string help, long step) {
ParamSpec spec; ParamSpec spec;
@@ -103,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) {
} // namespace } // namespace
ParamValue paramDefault(const ParamSpec& spec) {
switch (spec.type) {
case ParamSpec::Type::Int:
return static_cast<long>(spec.defaultValue);
case ParamSpec::Type::Double:
return spec.defaultValue;
case ParamSpec::Type::Bool:
return spec.defaultValue != 0.0;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
break;
}
return spec.defaultChoice;
}
long paramInt(const ParamValues& values, const std::string& key, long fallback) { long paramInt(const ParamValues& values, const std::string& key, long fallback) {
if (const auto* value = find(values, key)) { if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<long>(value)) return *v; if (const auto* v = std::get_if<long>(value)) return *v;
+12
View File
@@ -24,9 +24,17 @@ struct ParamSpec {
int digits = 2; int digits = 2;
std::string defaultChoice; std::string defaultChoice;
std::vector<std::string> choices; std::vector<std::string> choices;
// Whether a named style carries this param rather than the stage it sits
// on: see core/style.hpp.
bool inStyle = false;
std::string flag() const; // "strip_height" -> "--strip-height" std::string flag() const; // "strip_height" -> "--strip-height"
// A copy of this spec marked as carried by a style, so that a declaration
// reads makeDouble("font_size", ...).styled() and the factories keep the
// signatures they have.
ParamSpec styled() const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1); long maximum, std::string help = {}, long step = 1);
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue, static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
@@ -53,6 +61,10 @@ struct ParamSpec {
using ParamValue = std::variant<long, double, bool, std::string>; using ParamValue = std::variant<long, double, bool, std::string>;
using ParamValues = std::map<std::string, ParamValue>; using ParamValues = std::map<std::string, ParamValue>;
// The value a spec stands for when nothing has been set: one place, so that
// an effect's defaults and a style's are filled the same way.
ParamValue paramDefault(const ParamSpec& spec);
long paramInt(const ParamValues& values, const std::string& key, long fallback); long paramInt(const ParamValues& values, const std::string& key, long fallback);
double paramDouble(const ParamValues& values, const std::string& key, double fallback); double paramDouble(const ParamValues& values, const std::string& key, double fallback);
bool paramBool(const ParamValues& values, const std::string& key, bool fallback); bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
+156 -80
View File
@@ -325,6 +325,96 @@ std::string resolve(const std::string& path, const std::string& directory) {
return (std::filesystem::path(directory) / file).lexically_normal().string(); return (std::filesystem::path(directory) / file).lexically_normal().string();
} }
// The params of a stage or of a style, written from the specs rather than from
// whatever the values happen to hold: a key the effect does not declare is
// never written, so it can never be read back either.
void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const ParamValues& values,
const std::string& directory) {
out << "{";
bool first = true;
for (const auto& spec : specs) {
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
first = false;
switch (spec.type) {
case ParamSpec::Type::Bool:
out << (paramBool(values, spec.key, spec.defaultValue != 0.0) ? "true" : "false");
break;
case ParamSpec::Type::Int:
out << paramInt(values, spec.key, static_cast<long>(spec.defaultValue));
break;
case ParamSpec::Type::Double:
out << number(paramDouble(values, spec.key, spec.defaultValue), spec.digits);
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Text:
out << quote(paramText(values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
std::uint8_t rgb[3];
paramColor(values, spec.key, spec.defaultChoice, rgb);
out << quote(formatColor(rgb));
break;
}
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }");
}
// The mirror image: only declared keys are read, each behind the gate its type
// asks for, so anything else in the file leaves the default standing.
void readParams(const std::vector<ParamSpec>& specs, const Json& params,
const std::string& directory, ParamValues& values) {
for (const auto& spec : specs) {
const auto* value = params.member(spec.key);
if (value == nullptr) continue;
switch (spec.type) {
case ParamSpec::Type::Bool:
if (value->kind == Json::Kind::Bool) values[spec.key] = value->boolean;
break;
case ParamSpec::Type::Int:
if (value->kind == Json::Kind::Number) {
values[spec.key] = static_cast<long>(std::llround(value->number));
}
break;
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) values[spec.key] = value->number;
break;
case ParamSpec::Type::Path:
if (value->kind == Json::Kind::String) {
values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Text:
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
// A colour that does not parse keeps the default, exactly as
// an unknown choice does.
std::uint8_t rgb[3];
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
values[spec.key] = formatColor(rgb);
}
break;
}
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
values[spec.key] = choice;
}
}
break;
}
}
}
} // namespace } // namespace
std::string projectToJson(const Project& project, const std::string& directory) { std::string projectToJson(const Project& project, const std::string& directory) {
@@ -353,51 +443,37 @@ std::string projectToJson(const Project& project, const std::string& directory)
} }
out << (project.sources.empty() ? "" : "\n ") << "],\n"; out << (project.sources.empty() ? "" : "\n ") << "],\n";
// Before the chain, because a stage's "style" is a name from this list.
if (!project.styles.empty()) {
out << " \"styles\": [";
for (std::size_t i = 0; i < project.styles.size(); ++i) {
const auto& style = project.styles[i];
out << (i == 0 ? "\n" : ",\n");
out << " {\n \"kind\": " << quote(style.kind) << ",\n";
out << " \"name\": " << quote(style.name) << ",\n";
out << " \"params\": ";
writeParams(out, styleSpecs(style.kind), style.values, directory);
out << "\n }";
}
out << "\n ],\n";
}
out << " \"chain\": ["; out << " \"chain\": [";
for (std::size_t i = 0; i < project.stages.size(); ++i) { for (std::size_t i = 0; i < project.stages.size(); ++i) {
const auto& stage = project.stages[i]; const auto& stage = project.stages[i];
if (!stage.effect) continue; if (!stage.effect) continue;
out << (i == 0 ? "\n" : ",\n"); out << (i == 0 ? "\n" : ",\n");
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n"; out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
out << " \"params\": {"; // Only written when it is false: a stage is enabled unless the file
// says otherwise, so an ordinary project stays as uncluttered as it
const auto& specs = stage.effect->params(); // was before a stage could be switched off at all.
bool first = true; if (!stage.enabled) out << " \"enabled\": false,\n";
for (const auto& spec : specs) { // Likewise only when there is one: a stage that stands on its own
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": "; // values says nothing about styles at all.
first = false; if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
switch (spec.type) { out << " \"params\": ";
case ParamSpec::Type::Bool: writeParams(out, stage.effect->params(), stage.values, directory);
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true" out << "\n }";
: "false");
break;
case ParamSpec::Type::Int:
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
break;
case ParamSpec::Type::Double:
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
spec.digits);
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Text:
out << quote(paramText(stage.values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
out << quote(formatColor(rgb));
break;
}
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(stage.values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }") << "\n }";
} }
out << (project.stages.empty() ? "" : "\n ") << "]\n"; out << (project.stages.empty() ? "" : "\n ") << "]\n";
out << "}\n"; out << "}\n";
@@ -453,6 +529,33 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
} }
} }
// Read before the chain, so that a stage's "style" can be checked against
// the pool as it is read.
if (const auto* styles = root.member("styles")) {
for (const auto& item : styles->items) {
const auto* kind = item.member("kind");
const auto* name = item.member("name");
if (kind == nullptr || kind->kind != Json::Kind::String) continue;
if (name == nullptr || name->kind != Json::Kind::String || name->text.empty()) continue;
// A kind no effect in this build claims carries no params, so
// there would be nothing to keep; a repeated name would make
// "style": "Title" ambiguous, so the first one wins.
const auto specs = styleSpecs(kind->text);
if (specs.empty()) continue;
if (findStyle(project.styles, kind->text, name->text) != nullptr) continue;
Style style;
style.kind = kind->text;
style.name = name->text;
style.values = styleDefaults(style.kind);
if (const auto* params = item.member("params")) {
readParams(specs, *params, directory, style.values);
}
project.styles.push_back(std::move(style));
}
}
const auto* chain = root.member("chain"); const auto* chain = root.member("chain");
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) { if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
throw EffectError("the project has no effect chain"); throw EffectError("the project has no effect chain");
@@ -466,53 +569,26 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
ChainStage stage; ChainStage stage;
stage.effect = effectByName(name->text); // throws on an unknown effect stage.effect = effectByName(name->text); // throws on an unknown effect
stage.values = stage.effect->defaults(); stage.values = stage.effect->defaults();
if (const auto* enabled = item.member("enabled");
enabled != nullptr && enabled->kind == Json::Kind::Bool) {
stage.enabled = enabled->boolean;
}
// Driven by the specs, not by the file: a key the effect does not // Driven by the specs, not by the file: a key the effect does not
// declare is dropped, and an old project simply keeps the defaults for // declare is dropped, and an old project simply keeps the defaults for
// params that did not exist when it was written. // params that did not exist when it was written.
if (const auto* params = item.member("params")) { if (const auto* params = item.member("params")) {
for (const auto& spec : stage.effect->params()) { readParams(stage.effect->params(), *params, directory, stage.values);
const auto* value = params->member(spec.key);
if (value == nullptr) continue;
switch (spec.type) {
case ParamSpec::Type::Bool:
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
break;
case ParamSpec::Type::Int:
if (value->kind == Json::Kind::Number) {
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
}
break;
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
break;
case ParamSpec::Type::Path:
if (value->kind == Json::Kind::String) {
stage.values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Text:
if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
// A colour that does not parse keeps the default,
// exactly as an unknown choice does.
std::uint8_t rgb[3];
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
stage.values[spec.key] = formatColor(rgb);
}
break;
}
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
stage.values[spec.key] = choice;
}
}
break;
}
} }
// A style is kept only when the file actually holds one of that name
// for this effect's pool: a stage pointing at a style that is not
// there falls back to the values it wrote beside it, which are the
// ones it last rendered with.
if (const auto* style = item.member("style");
style != nullptr && style->kind == Json::Kind::String &&
findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) {
stage.style = style->text;
} }
project.stages.push_back(std::move(stage)); project.stages.push_back(std::move(stage));
} }
+8 -1
View File
@@ -8,6 +8,7 @@
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -17,6 +18,10 @@ namespace effector {
struct Project { struct Project {
std::vector<std::string> sources; // absolute paths std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages; std::vector<ChainStage> stages;
// The named looks the stages draw their formatting from. Kept beside the
// chain rather than inside it because that is the whole point of a style:
// several stages share one, and editing it moves all of them at once.
StyleSet styles;
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 named format the canvas came from, empty for a custom pixel size.
@@ -36,7 +41,9 @@ std::string projectToJson(const Project& project, const std::string& directory =
// Throws EffectError on malformed JSON, an unknown effect or a missing chain. // Throws EffectError on malformed JSON, an unknown effect or a missing chain.
// Relative source paths are resolved against `directory`; params the effect // Relative source paths are resolved against `directory`; params the effect
// does not declare are dropped, and missing ones keep the effect's default. // does not declare are dropped, and missing ones keep the effect's default. A
// style of an unknown kind, or a stage naming a style the file does not hold,
// is dropped the same way: the stage keeps its own values and still renders.
Project projectFromJson(const std::string& text, const std::string& directory = {}); Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported // Both throw EffectError: the file is the user's, so failures are reported
+39
View File
@@ -0,0 +1,39 @@
#include "core/style.hpp"
#include <algorithm>
#include "core/registry.hpp"
namespace effector {
std::vector<ParamSpec> styleSpecs(const std::string& kind) {
std::vector<ParamSpec> specs;
if (kind.empty()) return specs;
for (const auto& effect : effects()) {
if (effect->styleKind() != kind) continue;
for (const auto& spec : effect->params()) {
if (!spec.inStyle) continue;
const bool known = std::any_of(specs.begin(), specs.end(), [&](const ParamSpec& seen) {
return seen.key == spec.key;
});
if (!known) specs.push_back(spec);
}
}
return specs;
}
ParamValues styleDefaults(const std::string& kind) {
ParamValues values;
for (const auto& spec : styleSpecs(kind)) values[spec.key] = paramDefault(spec);
return values;
}
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name) {
if (kind.empty() || name.empty()) return nullptr;
for (const auto& style : styles) {
if (style.kind == kind && style.name == name) return &style;
}
return nullptr;
}
} // namespace effector
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#include "core/params.hpp"
namespace effector {
// A named set of formatting values, shared by every stage that follows it.
//
// A style carries only the params an effect marked styled() -- the look, never
// the placement -- so several text stages can share one typography while each
// keeps its own words and its own corner of the canvas. Changing the style is
// the point: it changes every stage pointing at it at once.
struct Style {
std::string kind; // the pool, as Effect::styleKind() names it
std::string name; // unique within a kind, never empty
ParamValues values; // the styled params only
};
using StyleSet = std::vector<Style>;
// The styled specs of a kind: every registered effect answering that kind
// contributes its styled params, the first declaration of a key winning. This
// is to a style what Effect::params() is to a stage -- the only thing the
// interface and the project file know about what a style holds -- so an empty
// or unknown kind gives an empty list.
std::vector<ParamSpec> styleSpecs(const std::string& kind);
// A fresh set of values for a style of that kind: every styled param at the
// default its effect declares, the way Effect::defaults() fills a stage.
ParamValues styleDefaults(const std::string& kind);
// The style of that name in that pool, or nullptr. A stage naming a style that
// is not there simply stands on its own values.
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name);
} // namespace effector
+20 -11
View File
@@ -17,31 +17,39 @@ const std::vector<ParamSpec>& TextEffect::params() const {
"What to write; a line break starts a new line."), "What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans", ParamSpec::makeText("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, " "Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ..."), "\"DejaVu Serif\", ...")
.styled(),
ParamSpec::makeChoice("font_weight", "Weight", "normal", ParamSpec::makeChoice("font_weight", "Weight", "normal",
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"}, {"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
"How bold the glyphs are; a family without the chosen weight is " "How bold the glyphs are; a family without the chosen weight is "
"rendered in the nearest one it has."), "rendered in the nearest one it has.")
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs."), .styled(),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(),
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0, ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
"Height of the glyphs in pixels.", 1.0, 1), "Height of the glyphs in pixels.", 1.0, 1)
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs."), .styled(),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"}, ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."), "How several lines line up against each other.")
.styled(),
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000, ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
"Breaks the text into lines no wider than this many pixels; 0 writes " "Breaks the text into lines no wider than this many pixels; 0 writes "
"it as given."), "it as given."),
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"}, ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
"What the text sits on: nothing, a filled rectangle, or a picture " "What the text sits on: nothing, a filled rectangle, or a picture "
"from disk cropped to the rectangle."), "from disk cropped to the rectangle.")
.styled(),
ParamSpec::makeColor("box_color", "Box colour", "#000000", ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."), "The colour the box is filled with.")
.styled(),
ParamSpec::makePath("box_image", "Box picture", ParamSpec::makePath("box_image", "Box picture",
"The picture the box is filled with, scaled to cover it."), "The picture the box is filled with, scaled to cover it."),
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000, ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
"How far the box reaches past the text on every side."), "How far the box reaches past the text on every side.")
.styled(),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0, ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."), "How strongly the box is mixed over the input.")
.styled(),
ParamSpec::makeBool("text_center", "Centre", true, ParamSpec::makeBool("text_center", "Centre", true,
"Places the text in the middle of the canvas rather than at its top " "Places the text in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."), "left corner; the offset is counted from there either way."),
@@ -50,7 +58,8 @@ const std::vector<ParamSpec>& TextEffect::params() const {
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000, ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."), "Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0, ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them."), "How strongly the glyphs are mixed over what is behind them.")
.styled(),
}; };
return specs; return specs;
} }
+1
View File
@@ -28,6 +28,7 @@ public:
"box filled with a colour or with a picture."; "box filled with a colour or with a picture.";
} }
const std::vector<ParamSpec>& params() const override; const std::vector<ParamSpec>& params() const override;
std::string styleKind() const override { return "text"; }
bool needsSources() const override { return false; } bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
+379 -19
View File
@@ -6,6 +6,7 @@
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include <utility>
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
@@ -49,7 +50,8 @@ EffectorWindow::EffectorWindow(const Options& initial) {
set_default_size(1180, 760); set_default_size(1180, 760);
effects_ = effects(); effects_ = effects();
stages_.push_back({effects_.front(), effects_.front()->defaults()}); // The chain starts empty: the interface opens on "No effects yet" and the
// user picks the first stage, rather than being handed one at random.
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
@@ -191,6 +193,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
return *sidebar; return *sidebar;
} }
// Only the first stage is ever handed the sources, so whether the chain wants
// them at all is `chainNeedsSources`. An effect that starts from nothing (blur,
// text) makes the whole sources section beside the point, so it is hidden
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesExpander_.set_visible(chainNeedsSources(stages_));
}
// The expander is collapsed by default, so its label carries the count that // The expander is collapsed by default, so its label carries the count that
// used to be plain to see in the list. // used to be plain to see in the list.
void EffectorWindow::updateSourcesLabel() { void EffectorWindow::updateSourcesLabel() {
@@ -350,27 +361,62 @@ void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child); while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear(); paramWidgets_.clear();
ownedWidgets_.clear(); ownedWidgets_.clear();
styleDrop_ = nullptr;
if (stages_.empty()) { if (stages_.empty()) {
paramHeading_.set_text("No effects"); paramHeading_.set_text("No effects");
return; return;
} }
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)]; const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() + paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings"); (stage.enabled ? " settings" : " settings (off)"));
int row = 0; int row = 0;
// The style the stage follows, when its effect takes one at all: the
// params it carries are then shown and edited on the style rather than on
// the stage, which is what makes one change move every stage using it.
const Style* style = selectedStyle();
if (!stage.effect->styleKind().empty()) {
auto label = std::make_unique<Gtk::Label>("Style");
label->set_xalign(0.0F);
label->set_tooltip_text(
"A named set of formatting shared by several stages: the font, the colour and the "
"box, but never the words or the placement.");
auto widget = buildStyleRow(stage);
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
for (const auto& spec : stage.effect->params()) { for (const auto& spec : stage.effect->params()) {
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
const ParamValues& values = shared ? style->values : stage.values;
auto label = std::make_unique<Gtk::Label>(spec.label); auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help); std::string help = spec.help;
if (shared) {
label->set_markup(Glib::Markup::escape_text(spec.label) +
" <span alpha='55%'>(style)</span>");
if (!help.empty()) help += "\n\n";
help += "Part of the style \"" + style->name +
"\": changing it changes every stage that follows it.";
}
if (!help.empty()) label->set_tooltip_text(help);
std::unique_ptr<Gtk::Widget> widget; std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) { switch (spec.type) {
case ParamSpec::Type::Bool: { case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>(); auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START); toggle->set_halign(Gtk::Align::START);
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0)); toggle->set_active(paramBool(values, spec.key, spec.defaultValue != 0.0));
toggle->property_active().signal_changed().connect( toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle); widget = std::move(toggle);
@@ -383,9 +429,9 @@ void EffectorWindow::buildParamWidgets() {
spin->set_increments(spec.step, spec.step * 10); spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.type == ParamSpec::Type::Int spin->set_value(spec.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key, ? static_cast<double>(paramInt(values, spec.key,
static_cast<long>(spec.defaultValue))) static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, spec.defaultValue)); : paramDouble(values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect( spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin); widget = std::move(spin);
@@ -397,7 +443,7 @@ void EffectorWindow::buildParamWidgets() {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6); auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>(); auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_hexpand(true); entry->set_hexpand(true);
entry->set_text(paramText(stage.values, spec.key, "")); entry->set_text(paramText(values, spec.key, ""));
entry->set_placeholder_text("No image chosen"); entry->set_placeholder_text("No image chosen");
entry->signal_activate().connect( entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -415,7 +461,7 @@ void EffectorWindow::buildParamWidgets() {
} }
case ParamSpec::Type::Text: { case ParamSpec::Type::Text: {
auto entry = std::make_unique<Gtk::Entry>(); auto entry = std::make_unique<Gtk::Entry>();
entry->set_text(paramText(stage.values, spec.key, spec.defaultChoice)); entry->set_text(paramText(values, spec.key, spec.defaultChoice));
// Re-rendering on every keystroke would start a render per // Re-rendering on every keystroke would start a render per
// letter typed, so the text is taken when the entry is left or // letter typed, so the text is taken when the entry is left or
// Enter is pressed. // Enter is pressed.
@@ -432,7 +478,7 @@ void EffectorWindow::buildParamWidgets() {
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create()); auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
button->set_halign(Gtk::Align::START); button->set_halign(Gtk::Align::START);
std::uint8_t rgb[3]; std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb); paramColor(values, spec.key, spec.defaultChoice, rgb);
Gdk::RGBA colour; Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0); colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour); button->set_rgba(colour);
@@ -445,7 +491,7 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices)); dropdown->set_model(Gtk::StringList::create(choices));
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice); const auto current = paramChoice(values, spec.key, spec.defaultChoice);
for (std::size_t i = 0; i < spec.choices.size(); ++i) { for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == current) dropdown->set_selected(i); if (spec.choices[i] == current) dropdown->set_selected(i);
} }
@@ -511,7 +557,282 @@ ParamValues EffectorWindow::paramValues() const {
// whenever they change; the other stages keep the settings they were given. // whenever they change; the other stages keep the settings they were given.
void EffectorWindow::storeParams() { void EffectorWindow::storeParams() {
if (stages_.empty()) return; if (stages_.empty()) return;
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues(); auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const ParamValues values = paramValues();
stage.values = values;
// A param the style carries is written to the style too -- that is how the
// look of every stage following it is changed from here. The stage keeps
// its own copy of the same value, so leaving the style, or losing it, does
// not move the picture.
if (Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = values.find(spec.key);
if (found != values.end()) style->values[spec.key] = found->second;
}
}
}
// ---------------------------------------------------------- text styles ---
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
std::vector<std::string> names;
for (const auto& style : styles_) {
if (style.kind == kind) names.push_back(style.name);
}
return names;
}
Style* EffectorWindow::selectedStyle() {
return const_cast<Style*>(std::as_const(*this).selectedStyle());
}
const Style* EffectorWindow::selectedStyle() const {
if (stages_.empty()) return nullptr;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
return findStyle(styles_, stage.effect->styleKind(), stage.style);
}
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
return findStyle(styles_, kind, name) != nullptr;
}
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
const auto names = styleNames(stage.effect->styleKind());
// "Custom" first so that the stage standing on its own is the plain case,
// and "New style…" last so that the names in between keep their indices.
std::vector<Glib::ustring> entries{"Custom"};
for (const auto& name : names) entries.emplace_back(name);
entries.emplace_back("New style…");
auto* drop = Gtk::make_managed<Gtk::DropDown>();
drop->set_model(Gtk::StringList::create(entries));
drop->set_hexpand(true);
suspendStyleSelect_ = true;
drop->set_selected(0);
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == stage.style) drop->set_selected(i + 1);
}
suspendStyleSelect_ = false;
drop->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
box->append(*drop);
styleDrop_ = drop;
// Renaming and removing only make sense for a style that is being followed.
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
rename->set_sensitive(!stage.style.empty());
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
box->append(*rename);
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
remove->set_sensitive(!stage.style.empty());
remove->set_tooltip_text(
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
"settings.");
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
box->append(*remove);
return box;
}
void EffectorWindow::onStyleSelected() {
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const auto names = styleNames(stage.effect->styleKind());
const auto index = styleDrop_->get_selected();
if (index == 0) {
adoptStyle("");
} else if (index <= names.size()) {
adoptStyle(names[index - 1]);
} else {
onNewStyle(); // the last entry
}
}
// Pointing the stage at a style, or at nothing. The style's values are copied
// onto the stage as well, so the project file always says what the stage would
// render if the style went away -- which is the same picture.
void EffectorWindow::adoptStyle(const std::string& name) {
if (stages_.empty()) return;
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
stage.style = name;
if (const Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) stage.values[spec.key] = found->second;
}
}
buildParamWidgets();
requestRender();
}
void EffectorWindow::onNewStyle() {
if (stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const std::string kind = stage.effect->styleKind();
if (kind.empty()) return;
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
[this, kind](const std::string& name) {
// Seeded from the stage it was made on, so the style
// starts as the look that is already on screen.
Style style;
style.kind = kind;
style.name = name;
style.values = styleDefaults(kind);
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
for (const auto& spec : current.effect->params()) {
if (!spec.inStyle) continue;
const auto found = current.values.find(spec.key);
if (found != current.values.end()) style.values[spec.key] = found->second;
}
styles_.push_back(std::move(style));
adoptStyle(name);
});
}
void EffectorWindow::onRenameStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string was = style->name;
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
if (name == was) return;
for (auto& candidate : styles_) {
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
}
// Every stage that followed the old name follows the new one: a rename
// must not quietly cut a stage loose from its look.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
}
buildParamWidgets();
});
}
void EffectorWindow::onRemoveStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string name = style->name;
// The stages keep the values they were rendering with, so dropping a style
// changes nothing in the picture -- it only stops the stages moving
// together.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
}
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
[&](const Style& candidate) {
return candidate.kind == kind && candidate.name == name;
}),
styles_.end());
buildParamWidgets();
requestRender();
}
// Built from scratch each time it is needed, like the progress window, so the
// entry it holds is a raw pointer that closeStyleWindow() nulls.
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept) {
closeStyleWindow();
if (stages_.empty()) return;
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
styleWindow_ = std::make_shared<Gtk::Window>();
styleWindow_->set_title(title);
styleWindow_->set_transient_for(*this);
styleWindow_->set_modal(true);
styleWindow_->set_resizable(false);
styleWindow_->set_default_size(360, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_text(initial);
entry->set_activates_default(false);
box->append(*entry);
styleNameEntry_ = entry;
auto* error = Gtk::make_managed<Gtk::Label>("");
error->set_xalign(0.0F);
error->add_css_class("dim-label");
error->set_visible(false);
box->append(*error);
styleNameError_ = error;
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
buttons->set_halign(Gtk::Align::END);
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->signal_clicked().connect([this] {
closeStyleWindow();
// The dropdown may be sitting on "New style…"; put it back on what the
// stage actually follows.
buildParamWidgets();
});
buttons->append(*cancel);
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
confirm->add_css_class("suggested-action");
// A name that is empty or already in the pool is refused in place rather
// than silently changed, so two styles can never share a name.
const auto submit = [this, kind, accept] {
if (styleNameEntry_ == nullptr) return;
const std::string name = styleNameEntry_->get_text();
const char* complaint = nullptr;
if (name.empty()) {
complaint = "A style needs a name.";
} else if (styleNameTaken(kind, name)) {
complaint = "There is already a style with that name.";
}
if (complaint != nullptr) {
if (styleNameError_ != nullptr) {
styleNameError_->set_text(complaint);
styleNameError_->set_visible(true);
}
return;
}
closeStyleWindow();
accept(name);
};
confirm->signal_clicked().connect(submit);
entry->signal_activate().connect(submit);
buttons->append(*confirm);
box->append(*buttons);
styleWindow_->signal_close_request().connect(
[this] {
closeStyleWindow();
buildParamWidgets();
return true;
},
false);
styleWindow_->set_child(*box);
styleWindow_->present();
entry->grab_focus();
}
void EffectorWindow::closeStyleWindow() {
styleNameEntry_ = nullptr;
styleNameError_ = nullptr;
if (!styleWindow_) return;
styleWindow_->set_hide_on_close(false);
styleWindow_->hide();
// Closing is asked for by the window's own buttons and by its close
// request, so it is torn down once that signal has finished running rather
// than underneath it.
Glib::signal_idle().connect_once([window = styleWindow_] {});
styleWindow_.reset();
} }
void EffectorWindow::applyInitial(const Options& initial) { void EffectorWindow::applyInitial(const Options& initial) {
@@ -903,6 +1224,12 @@ void EffectorWindow::rebuildChainRows() {
// removing it would leave the empty list blank. // removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row); while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
for (std::size_t i = 0; i < stages_.size(); ++i) { for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i); const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>(); auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
@@ -912,13 +1239,28 @@ void EffectorWindow::rebuildChainRows() {
box->set_margin_start(8); box->set_margin_start(8);
box->set_margin_end(4); box->set_margin_end(4);
// The check is what switches a stage off: it keeps its settings and
// its place in the chain, and the render simply skips it.
auto* check = Gtk::make_managed<Gtk::CheckButton>();
check->set_active(stages_[i].enabled);
check->set_tooltip_text("Apply this effect");
check->set_valign(Gtk::Align::CENTER);
// Connected after set_active, so filling in the rebuilt rows does not
// look like the user reaching for the check.
check->signal_toggled().connect([this, index, check] {
setStageEnabled(index, check->get_active());
});
box->append(*check);
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " + auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
stages_[i].effect->title()); stages_[i].effect->title());
label->set_xalign(0.0F); label->set_xalign(0.0F);
label->set_hexpand(true); label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END); label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(index == 0 ? "Applied to the source images" if (!stages_[i].enabled) label->add_css_class("dim-label");
: "Applied to the output of the stage above"); label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
box->append(*label); box->append(*label);
struct Action { struct Action {
@@ -952,6 +1294,20 @@ void EffectorWindow::rebuildChainRows() {
selectStage(selectedStage_); selectStage(selectedStage_);
suspendStageSelect_ = false; suspendStageSelect_ = false;
updateSourcesVisible();
}
// Switching a stage off leaves everything about it alone -- its settings, its
// place in the chain, the stage the params below are showing -- so the only
// visible change is the dimmed row and the picture the render comes back with.
void EffectorWindow::setStageEnabled(int index, bool enabled) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
auto& stage = stages_[static_cast<std::size_t>(index)];
if (stage.enabled == enabled) return;
stage.enabled = enabled;
rebuildChainRows(); // the row dims, and another stage may now lead the chain
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
requestRender();
} }
void EffectorWindow::selectStage(int index) { void EffectorWindow::selectStage(int index) {
@@ -1028,12 +1384,13 @@ void EffectorWindow::onRecreate() {
// ever handed the sources, so an effect that does not want them (blur) renders // ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded. // happily with none loaded.
bool EffectorWindow::canRender() const { bool EffectorWindow::canRender() const {
if (stages_.empty()) return false; if (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_); return !sources_->empty() || !chainNeedsSources(stages_);
} }
std::string EffectorWindow::missingText() const { std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start."; if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images to start."; return "Add source images to start.";
} }
@@ -1072,6 +1429,7 @@ void EffectorWindow::startRender() {
const auto sources = sources_; const auto sources = sources_;
const auto stages = stages_; // a snapshot, like the sources const auto stages = stages_; // a snapshot, like the sources
const auto styles = styles_; // the same, so the worker reads nothing live
const int width = static_cast<int>(widthSpin_.get_value()); const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value()); const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_; const std::uint64_t seed = seed_;
@@ -1079,13 +1437,13 @@ void EffectorWindow::startRender() {
spinner_.start(); spinner_.start();
recreateButton_.set_sensitive(false); recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, stages, width, height, seed] { worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
Image image; Image image;
std::string error; std::string error;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
try { try {
Rng rng(seed); Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng); image = renderChain(stages, *sources, width, height, rng, styles);
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
error = exception.what(); error = exception.what();
} }
@@ -1193,6 +1551,7 @@ Project EffectorWindow::currentProject() const {
Project project; Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path()); for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_; project.stages = stages_;
project.styles = styles_;
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.format = canvasFormatKey();
@@ -1204,9 +1563,12 @@ Project EffectorWindow::currentProject() const {
void EffectorWindow::applyProject(const Project& project, const std::string& path) { void EffectorWindow::applyProject(const Project& project, const std::string& path) {
// One render at the end rather than one per piece of the project. // One render at the end rather than one per piece of the project.
suspendRender_ = true; suspendRender_ = true;
closeStyleWindow(); // it names a style of the project being replaced
clearSources(); clearSources();
stages_ = project.stages; stages_ = project.stages;
// Before the rows and the params: both ask which style a stage follows.
styles_ = project.styles;
selectedStage_ = 0; selectedStage_ = 0;
rebuildChainRows(); rebuildChainRows();
buildParamWidgets(); buildParamWidgets();
@@ -1242,9 +1604,7 @@ void EffectorWindow::clearSources() {
} }
void EffectorWindow::onNewProject() { void EffectorWindow::onNewProject() {
Project fresh; applyProject(Project(), ""); // same empty chain the window opens with
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
applyProject(fresh, "");
} }
void EffectorWindow::onOpenProject() { void EffectorWindow::onOpenProject() {
+37 -1
View File
@@ -7,6 +7,7 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <functional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@@ -16,6 +17,7 @@
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -30,7 +32,29 @@ private:
Gtk::Widget& buildPreview(); Gtk::Widget& buildPreview();
void buildParamWidgets(); void buildParamWidgets();
ParamValues paramValues() const; ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values void storeParams(); // widgets -> the selected stage, and its style
// The style row above the params of a stage whose effect takes one.
std::unique_ptr<Gtk::Widget> buildStyleRow(const ChainStage& stage);
// The names of the pool the selected stage draws from, in the order the
// dropdown shows them.
std::vector<std::string> styleNames(const std::string& kind) const;
// The style the selected stage follows, or nullptr when it stands alone.
Style* selectedStyle();
const Style* selectedStyle() const;
void onStyleSelected(); // the dropdown: adopt, drop, or make a new one
void adoptStyle(const std::string& name);
void onNewStyle();
void onRenameStyle();
void onRemoveStyle();
// The little modal that asks for a style name, used by both New and
// Rename. Built from scratch each time, like the progress window.
void askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept);
void closeStyleWindow();
// A name that is empty, or already used in the pool, is refused in place.
bool styleNameTaken(const std::string& kind, const std::string& name) const;
void applyInitial(const Options& initial); void applyInitial(const Options& initial);
bool canRender() const; // a chain, and sources if the chain needs them bool canRender() const; // a chain, and sources if the chain needs them
@@ -49,6 +73,8 @@ 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
// The whole sources section only shows while the chain asks for sources.
void updateSourcesVisible();
Gtk::Widget& buildCanvasSection(); Gtk::Widget& buildCanvasSection();
// "" while "Custom" is picked, otherwise the format's key. // "" while "Custom" is picked, otherwise the format's key.
@@ -64,6 +90,8 @@ private:
void appendStage(const EffectPtr& effect); void appendStage(const EffectPtr& effect);
void removeStage(int index); void removeStage(int index);
void moveStage(int index, int delta); void moveStage(int index, int delta);
// The check on a chain row: the stage stays where it is, and is skipped.
void setStageEnabled(int index, bool enabled);
void rebuildChainRows(); void rebuildChainRows();
void selectStage(int index); void selectStage(int index);
void onStageSelected(); void onStageSelected();
@@ -110,6 +138,9 @@ private:
// The cascade: stage 0 works on the sources, every later stage on the // The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it. // output of the one before it.
std::vector<ChainStage> stages_; std::vector<ChainStage> stages_;
// The named looks the stages share. Edited through the params of whichever
// stage follows one, and saved with the project.
StyleSet styles_;
int selectedStage_ = 0; int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_; Image result_;
@@ -162,6 +193,11 @@ private:
// Set while a format fills the spins, so their own handler does not queue // 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. // a second render on top of the one the format change asks for.
bool suspendCanvasSync_ = false; bool suspendCanvasSync_ = false;
Gtk::DropDown* styleDrop_ = nullptr; // null while the stage takes no style
bool suspendStyleSelect_ = false; // guards filling the dropdown
std::shared_ptr<Gtk::Window> styleWindow_;
Gtk::Entry* styleNameEntry_ = nullptr; // nulled with the window
Gtk::Label* styleNameError_ = nullptr;
Gtk::Grid paramGrid_; Gtk::Grid paramGrid_;
Gtk::Label paramHeading_; Gtk::Label paramHeading_;
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
+8 -1
View File
@@ -18,7 +18,14 @@
namespace { namespace {
int renderHeadless(const effector::Options& options) { int renderHeadless(const effector::Options& options) {
const auto stages = effector::buildChain(options); auto stages = effector::buildChain(options);
// A headless run always renders something, so with no -e the default
// effect stands in. The interface has no such need: it opens on an empty
// chain and waits for the user to add the first stage.
if (stages.empty()) {
const auto effect = effector::effectByName(effector::defaultEffectName());
stages.push_back({effect, effect->defaults()});
}
std::vector<effector::LoadFailure> failures; std::vector<effector::LoadFailure> failures;
+229
View File
@@ -1,5 +1,6 @@
// Small self-contained checks for the pieces that have no GUI dependency. // Small self-contained checks for the pieces that have no GUI dependency.
#include <algorithm>
#include <array> #include <array>
#include <atomic> #include <atomic>
#include <clocale> #include <clocale>
@@ -21,6 +22,7 @@
#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 "core/style.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
@@ -828,6 +830,109 @@ int main() {
std::filesystem::remove_all(dir); std::filesystem::remove_all(dir);
} }
std::cout << "text styles\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const auto countRed = [](const Image& image) {
int found = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto specs = styleSpecs("text");
const auto carries = [&specs](const std::string& key) {
return std::any_of(specs.begin(), specs.end(),
[&key](const ParamSpec& spec) { return spec.key == key; });
};
check(specs.size() == 11, "a text style carries eleven params");
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
carries("font_size") && carries("text_color") && carries("text_align") &&
carries("text_opacity") && carries("text_box") && carries("box_color") &&
carries("box_padding") && carries("box_opacity"),
"the look is what it carries");
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
"the words and the placement stay on the stage");
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
"a kind no effect claims carries nothing");
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
styleDefaults("text").size() == specs.size(),
"a fresh style starts at the defaults the effect declares");
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
styles[0].values["font_size"] = 40.0;
styles[0].values["text_color"] = std::string("#ff0000");
check(findStyle(styles, "text", "Title") != nullptr &&
findStyle(styles, "text", "Caption") == nullptr &&
findStyle(styles, "other", "Title") == nullptr,
"a style is found by its kind and its name");
const ChainStage own{effectByName("text"),
{{"text", std::string("Hi")},
{"font_size", 12.0},
{"text_color", std::string("#ff0000")}},
true,
""};
ChainStage follows = own;
follows.style = "Title";
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
"a stage on its own keeps the settings it was given");
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
"a stage following a style takes the style's");
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
"and keeps its own words either way");
Rng ownRng(3);
Rng followRng(3);
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
check(countRed(small) > 0 && countRed(large) > countRed(small),
"the style is what actually reaches the render");
// The whole point: two stages, one style, one look in two places.
ChainStage left = follows;
left.values["text_offset_x"] = -40L;
ChainStage right = follows;
right.values["text_offset_x"] = 40L;
Rng leftRng(3);
Rng rightRng(3);
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
"two stages following one style ink the same glyphs");
check(!sameBytes(leftImage, rightImage), "each in its own place");
StyleSet edited = styles;
edited[0].values["font_size"] = 20.0;
Rng editedLeft(3);
Rng editedRight(3);
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
check(countRed(afterLeft) < countRed(leftImage) &&
countRed(afterRight) < countRed(rightImage) &&
countRed(afterLeft) == countRed(afterRight),
"editing the style moves every stage that follows it");
ChainStage missing = follows;
missing.style = "Nowhere";
Rng missingRng(3);
Rng bareRng(3);
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
renderChain({own}, blue, 200, 120, bareRng)),
"naming a style that is not there renders as standing alone");
Rng noStyleRng(3);
Rng noPoolRng(3);
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
renderChain({own}, blue, 200, 120, noPoolRng)),
"and so does a chain handed no styles at all");
}
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)};
@@ -889,6 +994,50 @@ int main() {
scanlinesThrew = true; scanlinesThrew = true;
} }
check(scanlinesThrew, "scanlines still refuses to run without sources"); check(scanlinesThrew, "scanlines still refuses to run without sources");
// A stage switched off is skipped whole: it is handed no image and
// draws nothing from the shared rng, so the cascade comes out exactly
// as it would have without that stage in it at all.
ChainStage blurOff = blur;
blurOff.enabled = false;
ChainStage scanlinesOff = scanlines;
scanlinesOff.enabled = false;
Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first
// stage still switched on.
check(!chainNeedsSources({scanlinesOff, blur}),
"a disabled leading stage leaves the question to the stage behind it");
Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead");
bool allOffThrew = false;
try {
Rng rng(1);
renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng);
} catch (const EffectError&) {
allOffThrew = true;
}
check(allOffThrew, "a chain with every stage disabled throws");
// The command line switches a stage off by effect name, the same way
// the params pick out the stage they belong to.
const auto cli =
parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"});
check(cli.has_value(), "--disable is accepted");
const auto cliStages = buildChain(*cli);
check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled,
"--disable keeps the stage in the chain and switches it off");
check(paramInt(cliStages[1].values, "radius", 0) == 4,
"a switched-off stage still takes its flags");
check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(),
"--disable refuses an unknown effect");
} }
std::cout << "project\n"; std::cout << "project\n";
@@ -949,6 +1098,86 @@ int main() {
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(), check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
"a stage only keeps the params its effect declares"); "a stage only keeps the params its effect declares");
// A stage switched off keeps its place and its settings in the file and
// comes back switched off; nothing is written for the ordinary case.
Project toggled = project;
toggled.stages[1].enabled = false;
const auto offFile = dir / "disabled.json";
saveProject(offFile.string(), toggled);
const auto offBack = loadProject(offFile.string());
check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled,
"a switched-off stage comes back switched off");
check(paramInt(offBack.stages[1].values, "radius", 0) == 3,
"and with the settings it was switched off with");
check(projectToJson(toggled).find("\"enabled\"") != std::string::npos &&
projectToJson(project).find("\"enabled\"") == std::string::npos,
"only a switched-off stage is written out as one");
// Styles ride along with the chain, and the stages come back pointing
// at the ones they followed.
Project stylish;
stylish.styles = {{"text", "Title", styleDefaults("text")},
{"text", "Caption", styleDefaults("text")}};
stylish.styles[0].values["font_size"] = 120.0;
stylish.styles[0].values["text_color"] = std::string("#ff8800");
stylish.styles[1].values["font_family"] = std::string("Serif");
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
const auto styleFile = dir / "styles.json";
saveProject(styleFile.string(), stylish);
const auto styleBack = loadProject(styleFile.string());
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
"the styles survive a round trip");
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
"and so do the values they carry");
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
styleBack.stages[2].style.empty(),
"and which stage follows which");
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
120.0 &&
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
0.0) == 96.0,
"a reloaded project resolves its styles as it did");
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
projectToJson(project).find("\"styles\"") == std::string::npos &&
projectToJson(project).find("\"style\"") == std::string::npos,
"a project with no styles writes nothing about them");
// Everything that can be wrong with a style is dropped on its own,
// leaving a project that still opens and still renders.
const auto lenient = projectFromJson(R"({
"styles": [
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
{"kind": "text", "name": "", "params": {}},
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
],
"chain": [
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
{"effect": "blur", "style": "Title", "params": {}}
]
})");
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
"an unknown kind, a nameless style and a repeated name are dropped");
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
"a style only keeps the params its kind declares, and the first of a name wins");
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
"a stage naming a style that is not there is cut loose");
check(lenient.stages[2].style.empty(),
"and so is one whose effect takes no style at all");
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
"which leaves it rendering the values written beside it");
int refusals = 0; int refusals = 0;
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}", for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) { "{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {