Sharper canvas, PSD adjustments and masks, Photoshop's Saturation, Levels and Hue/Saturation on every core, a growing blur's preview kept, the hierarchy kept (13.8)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 17:47:51 -04:00
1 parent 765e071c0c
commit cb85b86d58
46 files changed
+1041 -186

No files matched your search

+32
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <cstring>
#include <numeric>
#include <stdexcept>
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
return surface;
}
QImage BrushRaster::copy(const QImage &image)
{
QImage result = context(image.width(), image.height(), false);
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
QPainter painter(&result);
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return result;
}
for (int y = 0; y < image.height(); ++y)
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
return result;
}
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
{
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
const qsizetype size = (count + bands - 1) / bands;
try {
std::vector<int> indices(static_cast<size_t>(bands));
std::iota(indices.begin(), indices.end(), 0);
PoolMap::blocking(indices, [&](int band) {
const qsizetype start = band * size;
if (start < count)
body(start, std::min(size, count - start));
});
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
+4
View File
@@ -44,6 +44,10 @@ struct BrushSettings {
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// A colour context holding `image`; its own layout copies bytewise.
QImage copy(const QImage &image);
// Runs `body` over `count` pixels, band by band: start, length.
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
// Copies `image`, or its `source` part, into `rect`.
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
// A solid colour through gray coverage, and a clip, 1:1.
+2 -2
View File
@@ -104,8 +104,8 @@ struct SessionJobs {
FilterSettings settings;
// Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt;
// Its grid's version: a newer grid drops it.
quint64 sourceVersion = 0;
// The grown layer it was made from: it shows there.
std::optional<LayerTransform> placement = std::nullopt;
};
struct FilterMade {
std::optional<ImportedImage> asset;
+2 -1
View File
@@ -75,7 +75,8 @@ struct CanvasDocument {
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
// Drawing order and what shows (LayerOrder), kept between asks.
LayerOrder::Result hierarchy() const;
QSet<QUuid> effectiveVisibleIDs() const;
QHash<QUuid, double> effectiveOpacities() const;
std::vector<ImageLayer> renderLayers() const;
+2 -2
View File
@@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
return *pending;
// Content-Aware Fill past the edge previews on the grown layer.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->grownTransform;
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->preparedTransform;
return layer.transform;
}
+12 -16
View File
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
// A waiting job moves to the grid its version names.
if (edit.pending)
edit.pending = edit.previewJob();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
return;
}
edit.pending = edit.previewJob();
edit.pendingTransform = edit.grownTransform;
renderFilterPreview();
}
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
try {
if (job.kind == FilterKind::cameraRaw) {
auto [image, scope] = CameraRawScope::preview(job);
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
}
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ProjectError &error) {
// Settings past their bounds: Black & White, Color Balance.
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
}
}));
}
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// A cancelled edit takes nothing; an older grid renders again.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
m_filterEdit->preparing = false;
notify();
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
// A cancelled edit takes nothing.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (result.scope)
edit.rawPanel.scope = result.scope;
// Made before the layer grew, it shows there till replaced.
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedTransform = result.placement;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
-1
View File
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
++previewSourceVersion;
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
+4 -2
View File
@@ -171,10 +171,12 @@ public:
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// Where the prepared preview sits; none: the layer's own place.
std::optional<LayerTransform> preparedTransform;
// The grown layer `pending` is made from.
std::optional<LayerTransform> pendingTransform;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
// Moves with each new grid: an older render lands nothing.
quint64 previewSourceVersion = 0;
std::optional<FilterJob> pending;
CameraRawPanel rawPanel;
// Vignette on an empty layer: the canvas it fills.
+62 -59
View File
@@ -1,13 +1,14 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/HueSaturation.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <deque>
#include <mutex>
extern "C" {
#include "LevelsPixels.h"
}
namespace {
struct HSL {
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
}
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
// The last tables built: building costs more than applying.
struct CubeCache {
std::mutex lock;
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
};
CubeCache &cubeCache()
{
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
PoolMap::blocking(rows, [&](int y) {
uchar *pixel = bits + y * stride;
for (int x = 0; x < width; ++x, pixel += 4) {
const float alpha = pixel[3];
if (alpha == 0) {
std::fill_n(pixel, 3, uchar(0));
continue;
static CubeCache cache;
return cache;
}
std::vector<float> built(const HueSaturationSettings &settings)
{
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
constexpr int dimension = HueSaturationFilter::dimension;
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
float place[3], fraction[3];
int low[3];
for (int channel = 0; channel < 3; ++channel) {
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
low[channel] = std::min(int(place[channel]), last - 1);
fraction[channel] = place[channel] - float(low[channel]);
}
float out[3] = {0, 0, 0};
for (int corner = 0; corner < 8; ++corner) {
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
for (int channel = 0; channel < 3; ++channel)
out[channel] += share * entry[channel];
}
for (int channel = 0; channel < 3; ++channel)
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
}
});
}
return values;
}
}
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
{
const std::vector<HueResponse> response = hueResponse(settings);
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const Color color = adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
CubeCache &cache = cubeCache();
{
const std::lock_guard<std::mutex> held(cache.lock);
for (const auto &[known, values] : cache.cubes) {
if (known == settings)
return values;
}
}
std::vector<float> values = built(settings);
const std::lock_guard<std::mutex> held(cache.lock);
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
cache.cubes.emplace_front(settings, values);
if (cache.cubes.size() > 8)
cache.cubes.pop_back();
return values;
}
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
hue = std::fmod(hue + sampled.shift, 360);
if (hue < 0)
hue += 360;
// Multiplied, so neutral grays stay neutral.
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
saturation = adjustedSaturation(saturation, sampled.saturation);
}
// Toward white above zero, black below, reaching either at 100.
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
}
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
{
const double share = std::min(1.0, std::max(-1.0, amount / 100));
// Multiplied both ways, so neutral grays stay neutral.
if (!(share > 0))
return std::max(0.0, saturation * (1 + share));
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
}
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
{
double shift = 0;
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
{
const int width = job.image.width(), height = job.image.height();
QImage result = BrushRaster::context(width, height, false);
{
QPainter painter(&result);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
}
// Across the cores: it runs over the view each frame.
QImage result = BrushRaster::copy(job.image);
std::vector<float> table;
std::vector<int> rows;
try {
table = cube(job.settings);
rows.resize(size_t(height));
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
std::iota(rows.begin(), rows.end(), 0);
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
// The lookup unpremultiplies around itself.
uchar *const pixels = result.bits();
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
// Swift blends over the image drawn again: the same pixels.
if (job.selection)
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
+2
View File
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
std::vector<float> cube(const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
// Photoshop's: down toward gray, up by dividing by what's left.
double adjustedSaturation(double saturation, double amount);
// The hue a spectrum swatch becomes.
double shiftedHue(double hue, const HueSaturationSettings &settings);
}
+2 -1
View File
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
// Swift's region origin, counted in the image's own pixels.
if (region)
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
region->top() * image.height() / std::max(1.0, region->height()));
return PixelFilter::run(job);
}
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
+108
View File
@@ -0,0 +1,108 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include <map>
#include <mutex>
#include <stdexcept>
namespace {
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
{
if (depth > 64)
return;
const auto found = children.find(parent);
if (found == children.end())
return;
for (const LayerOrder::Node *node : found->second) {
const bool effective = shown && node->isVisible;
result.order.push_back(node->id);
if (effective) {
result.visible.insert(node->id);
if (!node->isGroup)
result.drawn.push_back(node->id);
}
if (node->isGroup)
visit(children, node->id, depth + 1, effective, result);
}
}
// The last nodes asked about and their answer.
struct Known {
std::mutex lock;
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
};
Known &known()
{
static Known value;
return value;
}
}
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
{
std::vector<Node> nodes;
nodes.reserve(layers.size());
for (const ImageLayer &layer : layers)
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
Known &cache = known();
{
const std::lock_guard<std::mutex> held(cache.lock);
if (cache.last && cache.last->first == nodes)
return cache.last->second;
}
Children children;
for (const Node &node : nodes)
children[node.parentID].push_back(&node);
Result result;
visit(children, std::nullopt, 0, true, result);
const std::lock_guard<std::mutex> held(cache.lock);
cache.last.emplace(std::move(nodes), result);
return result;
}
LayerOrder::Result CanvasDocument::hierarchy() const
{
return LayerOrder::resolve(layers);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
return hierarchy().visible;
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
// Each layer's opacity and folder, no whole layers copied.
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
for (const ImageLayer &layer : layers) {
if (folders.contains(layer.id))
throw std::logic_error("two layers share an id");
folders.insert(layer.id, {layer.opacity, layer.parentID});
}
QHash<QUuid, double> result;
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
}));
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
const std::vector<QUuid> order = hierarchy().drawn;
if (order.empty())
return {};
std::map<QUuid, size_t> index;
for (size_t place = 0; place < layers.size(); ++place) {
if (!index.emplace(layers[place].id, place).second)
throw std::logic_error("two layers share an id");
}
std::vector<ImageLayer> result;
result.reserve(order.size());
for (const QUuid id : order)
result.push_back(layers[index.at(id)]);
return result;
}
+4 -51
View File
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
});
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
std::map<QUuid, ImageLayer> byID;
for (const ImageLayer &layer : layers) {
if (!byID.emplace(layer.id, layer).second)
throw std::logic_error("two layers share an id");
}
QHash<QUuid, double> result;
for (const ImageLayer &layer : layers)
result.insert(layer.id, layer.effectiveOpacity(byID));
return result;
}
ProjectLayerRecord ImageLayer::hierarchyRecord() const
{
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
.text = typed ? std::optional(typed->style) : std::nullopt};
}
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
return LayerHierarchy::entries(records);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
QSet<QUuid> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible)
result.insert(entry.layer.id);
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
QHash<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : layers) {
if (byID.contains(layer.id))
throw std::logic_error("two layers share an id");
byID.insert(layer.id, &layer);
}
std::vector<ImageLayer> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(*byID.value(entry.layer.id));
}
return result;
}
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
{
dropEffectSelection();
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
}
std::vector<QUuid> ordered;
std::vector<Chain> chains;
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
if (rootIDs.contains(entry.layer.id)) {
ordered.push_back(entry.layer.id);
chains.push_back(ancestors(entry.layer.id));
for (const QUuid id : m_document->hierarchy().order) {
if (rootIDs.contains(id)) {
ordered.push_back(id);
chains.push_back(ancestors(id));
}
}
// The nearest folder they all share; the root when none.
+23
View File
@@ -23,3 +23,26 @@ namespace LayerOpacity {
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
double effective(double own, std::optional<QUuid> parent, const Node &folder);
}
struct ImageLayer;
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
namespace LayerOrder {
struct Node {
QUuid id;
std::optional<QUuid> parentID;
bool isGroup;
bool isVisible;
friend bool operator==(const Node &, const Node &) = default;
};
struct Result {
// Every layer and folder, as LayerHierarchy::entries lists them.
std::vector<QUuid> order;
// Visible, in folders that are.
QSet<QUuid> visible;
// The layers that show, folders left out, bottom to top.
std::vector<QUuid> drawn;
};
// Kept until a node changes: the canvas asks each event.
Result resolve(const std::vector<ImageLayer> &layers);
}
+1 -1
View File
@@ -392,7 +392,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
if (!layer.mask)
return std::nullopt;
// Previewed on a grown layer, the mask keeps its bounds.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return layer.mask->placement.value_or(layer.transform);
if (m_transformEdit && m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
+5 -3
View File
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
{
if (job.settings.isIdentity())
return job.image;
QImage context = drawn(job.image);
QImage context = BrushRaster::copy(job.image);
// On the stack: a heap failure would escape the catch.
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
// The kernel unpremultiplies edges itself; bands use every core.
uchar *const pixels = context.bits();
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
if (job.selection)
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
return context;
+33 -1
View File
@@ -1,6 +1,10 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "Document/PixelAdjust.h"
#include "IO/ProjectStore.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
namespace {
ImportedImage imported(const QImage &image, const QString &name)
@@ -114,7 +118,10 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
ImageLayer layer = layerFor(record, canvas, assets, typed);
if (record.mask) {
try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
const std::optional<QImage> placed = maskOnLayerGrid(*record.mask, record, layer, canvas);
if (!placed)
throw ExportError(ExportError::Kind::render);
layer.mask = LayerMask(LayerMask::assetFrom(*placed), record.maskEnabled, std::nullopt, record.maskLinked);
} catch (const std::runtime_error &) {
// Swift's `try?`: a mask that fails is reported, not fatal.
conversions.push_back(PSDConversion{.layerName = record.name,
@@ -147,3 +154,28 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
}
return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)};
}
std::optional<QImage> PSDDocumentBuilder::maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas)
{
const QSizeF grid = layer.asset ? QSizeF(layer.asset->size()) : canvas;
const QRectF placed(layer.transform.origin, layer.transform.size);
if (grid.width() < 1 || grid.height() < 1 || !(placed.width() > 0) || !(placed.height() > 0) || !(record.maskBounds.width() > 0)
|| !(record.maskBounds.height() > 0))
return patch;
const double scaleX = grid.width() / placed.width(), scaleY = grid.height() / placed.height();
const QRectF rect((record.maskBounds.left() - placed.left()) * scaleX, (record.maskBounds.top() - placed.top()) * scaleY,
record.maskBounds.width() * scaleX, record.maskBounds.height() * scaleY);
const int width = int(grid.width()), height = int(grid.height());
// Already the layer's grid: nothing to place.
if (rect.toAlignedRect() == QRect(0, 0, width, height) && patch.size() == QSize(width, height))
return patch;
QImage context(width, height, QImage::Format_Grayscale8);
if (context.isNull()) {
qCWarning(lcIO) << "a PSD layer mask could not be placed on its layer:" << QSize(width, height);
return std::nullopt;
}
context.fill(uint(record.maskDefault));
QPainter painter(&context);
BrushRaster::draw(patch, rect, painter);
return context;
}
+2
View File
@@ -15,4 +15,6 @@ namespace PSDDocumentBuilder {
// Each pixel record's asset and thumbnail, off the UI thread.
std::map<QUuid, ImportedImage> assets(const PSDDocument &document);
PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {});
// The stored patch on the layer's grid; none without memory.
std::optional<QImage> maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas);
}
+25 -11
View File
@@ -1,6 +1,7 @@
#include "IO/PSD/PSDReader.h"
#include <QtEndian>
#include <algorithm>
#include <cmath>
namespace {
quint16 u16(const QByteArray &data, qsizetype at)
@@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at)
{
return qFromBigEndian<qint16>(data.constData() + at);
}
}
std::optional<LayerAdjustment> levels(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::levels(const QByteArray &data)
{
if (data.size() < 292)
return std::nullopt;
LevelsSettings settings;
for (int channel = 0; channel < 4; ++channel) {
const qsizetype base = 2 + channel * 10;
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)),
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 100, double(u16(data, base + 2)),
double(u16(data, base + 4)), double(u16(data, base + 6))}
.normalized();
}
return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings};
}
namespace {
std::optional<LayerAdjustment> curves(const QByteArray &data)
{
if (data.size() < 5)
@@ -66,22 +69,33 @@ std::optional<LayerAdjustment> curves(const QByteArray &data)
return std::nullopt;
return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings};
}
}
std::optional<LayerAdjustment> hue(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::hue(const QByteArray &data)
{
if (data.size() < 4)
if (data.size() < 16)
return std::nullopt;
HueSaturationSettings settings(0, 0, 0, data[2] != 0);
qsizetype at = 4;
for (const ColorRange range : allColorRanges) {
if (at + 6 > data.size())
const bool colorize = data[2] != 0;
HueSaturationSettings settings(0, 0, 0, colorize);
const auto values = [&data](qsizetype at) { return RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; };
// Colorize has values of its own; else the Master's.
settings.adjustments[ColorRange::master] = values(colorize ? 4 : 10);
if (colorize)
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
qsizetype at = 16;
for (const ColorRange range : colorRanges) {
if (at + 14 > data.size())
break;
settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))};
at += 6;
const auto degrees = [&data](qsizetype from) {
const double value = std::fmod(double(i16(data, from)), 360);
return value < 0 ? value + 360 : value;
};
settings.bands[range] = HueBand{degrees(at), degrees(at + 2), degrees(at + 4), degrees(at + 6)};
settings.adjustments[range] = values(at + 8);
at += 14;
}
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
}
}
std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra)
{
+1 -2
View File
@@ -180,8 +180,7 @@ PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB)
layer.maskRight = cursor.i32();
layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft;
layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight;
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
layer.maskDefault = cursor.u8();
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
+2
View File
@@ -50,6 +50,8 @@ struct RawLayer {
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0;
// The mask's value outside its stored bounds.
uchar maskDefault = 255;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
+2
View File
@@ -80,6 +80,8 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
}
}
record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage;
record.maskBounds = QRectF(layer.maskLeft, layer.maskTop, layer.maskRight - layer.maskLeft, layer.maskBottom - layer.maskTop);
record.maskDefault = layer.maskDefault;
record.maskEnabled = !layer.maskDisabled;
record.maskLinked = layer.maskLinked;
if (!isGroup)
+4
View File
@@ -21,4 +21,8 @@ extern const std::set<QString> adjustmentKeys;
// Levels, Curves and Hue/Saturation layers, as Swift reads them.
namespace PSDAdjustments {
std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra);
// 'levl': each channel's levels, gamma in hundredths.
std::optional<LayerAdjustment> levels(const QByteArray &data);
// 'hue2': Colorize or the Master, then each range's values.
std::optional<LayerAdjustment> hue(const QByteArray &data);
}
+3
View File
@@ -38,6 +38,9 @@ struct PSDRecord {
QRectF bounds;
std::optional<QImage> image;
std::optional<QImage> mask;
// Where `mask` sits, and Photoshop's value everywhere else.
QRectF maskBounds;
uchar maskDefault = 255;
bool maskEnabled = true;
bool maskLinked = true;
std::optional<LayerAdjustment> adjustment;
+12 -3
View File
@@ -1,6 +1,7 @@
#include "Rendering/AdjustmentSurface.h"
#include "Document/BrushStroke.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QPaintEngine>
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget)
@@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
// Spatial adjustments read a halo; the clip keeps what shows.
const QRectF shown = toDevice.inverted().mapRect(QRectF(output));
const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output;
if (qint64(area.width()) * area.height() > pixelBudget) {
// Device pixels; past the budget, painter units.
double scale = 1;
QSize size = area.size();
if (qint64(size.width()) * size.height() > pixelBudget) {
scale = 1 / LayerRenderer::deviceScale(context);
size = QSize(std::max(1, qRound(area.width() * scale)), std::max(1, qRound(area.height() * scale)));
}
if (qint64(size.width()) * size.height() > pixelBudget) {
qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size();
return;
}
QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied);
QImage surface(size, QImage::Format_RGBA8888_Premultiplied);
if (surface.isNull()) {
qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size();
return;
@@ -28,12 +36,13 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
{
// The body draws in the painter's own coordinates.
QPainter painter(&surface);
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()));
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()) * QTransform::fromScale(scale, scale));
body(painter);
}
// Drawn back under the painter's own opacity, mode and clip.
context.save();
context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform());
context.setRenderHint(QPainter::SmoothPixmapTransform, scale != 1);
context.drawImage(QRectF(area), surface);
context.restore();
}
+3 -3
View File
@@ -56,7 +56,7 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
const double device = deviceScale(context);
const Reduced source = reduced(image, width, device, transform.sampling);
const InterpolationQuality quality = interpolation(
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level));
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
const QRectF bounds(-width / 2, -height / 2, width, height);
QTransform placement;
placement.translate(center.x(), center.y());
@@ -294,9 +294,9 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
context.restore();
}
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor)
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
{
if (sampling == LayerSampling::nearest)
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
return InterpolationQuality::none;
return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling);
}
+2 -1
View File
@@ -57,7 +57,8 @@ void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPoi
void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling,
InterpolationQuality quality, const Options &options, const QRectF &maskBounds,
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor);
// An upright layer drawn pixel for pixel copies straight across.
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
double deviceScale(const QPainter &context);
+2 -1
View File
@@ -150,7 +150,8 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
return;
const double device = LayerRenderer::deviceScale(context);
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level));
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
transform.radians() == 0);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());