Let several text stages share one named look
A picture with a title, a caption and a credit meant typing the same font, size and colour into every text stage, and changing the look meant typing them all again. A project now holds named styles: a stage follows one, and editing it moves every stage that follows it at once. The front-ends learn nothing effect-specific. A ParamSpec can be marked .styled(), saying a style carries it rather than the stage, and Effect::styleKind() names the pool it comes from -- so styleSpecs(kind) is to a style what params() is to a stage, and a second text-ish effect would slot into the styles that already exist. The look is what a style carries; the words and the placement stay on the stage, which is what lets two stages share a typography in two different corners. stageValues() resolves one in a single place, and a stage 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 it renders exactly the same picture. A style moves stages together; it never holds the only copy of anything. The command line builds no styles and passes none, so it renders what it always did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
||||||
@@ -82,6 +93,18 @@ 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
|
did not run. A chain with every stage disabled throws rather than rendering
|
||||||
nothing.
|
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, switch off with the check on the row, one stage selected
|
menu, reorder, remove, switch off with the check on the row, one stage selected
|
||||||
at a time with its params below), the CLI builds it from repeated `-e`:
|
at a time with its params below), the CLI builds it from repeated `-e`:
|
||||||
@@ -104,12 +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 (and its `"enabled": false`, written only for a stage
|
named styles, the chain with its params (and its `"enabled": false` and
|
||||||
that is switched off), canvas and seed — as a small JSON file, with its own
|
`"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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+20
-3
@@ -11,8 +11,26 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
|||||||
return running;
|
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) {
|
for (const auto& stage : stages) {
|
||||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
@@ -28,8 +46,7 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
|||||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
for (std::size_t i = 0; i < running.size(); ++i) {
|
||||||
const auto& stage = *running[i];
|
const auto& stage = *running[i];
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+14
-1
@@ -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 {
|
||||||
|
|
||||||
@@ -25,8 +26,18 @@ struct ChainStage {
|
|||||||
// the chain, so a stage can be switched off and back on while trying
|
// the chain, so a stage can be switched off and back on while trying
|
||||||
// things out without being taken apart and rebuilt.
|
// things out without being taken apart and rebuilt.
|
||||||
bool enabled = true;
|
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 = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The values a stage actually runs with: its effect's defaults, then the
|
||||||
|
// stage's own values, then the values of the style it follows for the params
|
||||||
|
// 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
|
// 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
|
// 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
|
// renders exactly as it would if that stage had never been added -- this is
|
||||||
@@ -39,8 +50,10 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
|||||||
//
|
//
|
||||||
// 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. Throws when no stage is left to run.
|
// 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 that
|
// Whether the chain has to be given source images. Only the first stage that
|
||||||
// runs ever sees them — every later one works on the stage before it — so this
|
// runs ever sees them — every later one works on the stage before it — so this
|
||||||
|
|||||||
+1
-19
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+149
-81
@@ -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,6 +443,21 @@ 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];
|
||||||
@@ -363,45 +468,12 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
|||||||
// says otherwise, so an ordinary project stays as uncluttered as it
|
// says otherwise, so an ordinary project stays as uncluttered as it
|
||||||
// was before a stage could be switched off at all.
|
// was before a stage could be switched off at all.
|
||||||
if (!stage.enabled) out << " \"enabled\": false,\n";
|
if (!stage.enabled) out << " \"enabled\": false,\n";
|
||||||
out << " \"params\": {";
|
// Likewise only when there is one: a stage that stands on its own
|
||||||
|
// values says nothing about styles at all.
|
||||||
const auto& specs = stage.effect->params();
|
if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
|
||||||
bool first = true;
|
out << " \"params\": ";
|
||||||
for (const auto& spec : specs) {
|
writeParams(out, stage.effect->params(), stage.values, directory);
|
||||||
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
|
out << "\n }";
|
||||||
first = false;
|
|
||||||
switch (spec.type) {
|
|
||||||
case ParamSpec::Type::Bool:
|
|
||||||
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
|
|
||||||
: "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";
|
||||||
@@ -457,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");
|
||||||
@@ -479,48 +578,17 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
|||||||
// 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) {
|
// A style is kept only when the file actually holds one of that name
|
||||||
case ParamSpec::Type::Bool:
|
// for this effect's pool: a stage pointing at a style that is not
|
||||||
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
|
// there falls back to the values it wrote beside it, which are the
|
||||||
break;
|
// ones it last rendered with.
|
||||||
case ParamSpec::Type::Int:
|
if (const auto* style = item.member("style");
|
||||||
if (value->kind == Json::Kind::Number) {
|
style != nullptr && style->kind == Json::Kind::String &&
|
||||||
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
|
findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) {
|
||||||
}
|
stage.style = style->text;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
project.stages.push_back(std::move(stage));
|
project.stages.push_back(std::move(stage));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+325
-11
@@ -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"
|
||||||
@@ -360,6 +361,7 @@ 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");
|
||||||
@@ -372,17 +374,49 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
(stage.enabled ? " settings" : " settings (off)"));
|
(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);
|
||||||
@@ -395,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);
|
||||||
@@ -409,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));
|
||||||
@@ -427,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.
|
||||||
@@ -444,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);
|
||||||
@@ -457,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);
|
||||||
}
|
}
|
||||||
@@ -523,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) {
|
||||||
@@ -1120,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_;
|
||||||
@@ -1127,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();
|
||||||
}
|
}
|
||||||
@@ -1241,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();
|
||||||
@@ -1252,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();
|
||||||
|
|||||||
+33
-1
@@ -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
|
||||||
@@ -114,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_;
|
||||||
@@ -166,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_;
|
||||||
|
|||||||
@@ -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)};
|
||||||
@@ -1008,6 +1113,71 @@ int main() {
|
|||||||
projectToJson(project).find("\"enabled\"") == std::string::npos,
|
projectToJson(project).find("\"enabled\"") == std::string::npos,
|
||||||
"only a switched-off stage is written out as one");
|
"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"}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user