Blur and noise adjustment layers, Image Trim, Inner Glow (12.8c)

This commit is contained in:
雷电芽衣 committed 2026-09-28 08:35:59 -04:00
1 parent 7ae263de80
commit 31e2dd3864
60 files changed
+1673 -105

No files matched your search

+14 -13
View File
@@ -27,16 +27,6 @@ bool opened(EditorSession &session, QUuid id)
{
return awaited([&](std::function<void()> done) { session.beginAdjustmentEditing(id, std::move(done)); });
}
// The pixels the open editor reads, premultiplied RGBA.
std::vector<int> input(const EditorSession &session)
{
const QImage image = session.levels().value().original.image().convertToFormat(QImage::Format_RGBA8888_Premultiplied);
std::vector<int> result;
for (int y = 0; y < image.height(); ++y)
result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4);
return result;
}
}
class AdjustmentEditorTests : public QObject {
@@ -104,6 +94,9 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
case AdjustmentKind::exposure:
case AdjustmentKind::gradientMap:
case AdjustmentKind::grain:
case AdjustmentKind::addNoise:
case AdjustmentKind::gaussianBlur:
case AdjustmentKind::motionBlur:
case AdjustmentKind::blackWhite:
case AdjustmentKind::colorBalance: {
QVERIFY(session.filterEdit().value().kind == filterKind(kind));
@@ -116,12 +109,15 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
settings.blackWhite.reds = 100;
else if (kind == AdjustmentKind::colorBalance)
settings.colorBalance.midCyanRed = 50;
else if (kind != AdjustmentKind::grain)
(kind == AdjustmentKind::gaussianBlur ? settings.radius : kind == AdjustmentKind::motionBlur ? settings.distance : settings.amount) = 40;
else
settings.grain.amount = 70;
session.updateFilter(settings, true);
const LayerAdjustment live = session.activeLayer().value().adjustment.value();
QVERIFY(live.exposure() == settings.exposure && live.gradientMap() == settings.gradientMap && live.grain() == settings.grain
&& live.blackWhite() == settings.blackWhite && live.colorBalance() == settings.colorBalance);
&& live.blackWhite() == settings.blackWhite && live.colorBalance() == settings.colorBalance && live.gaussianRadius() == settings.radius
&& live.resolvedMotionDistance() == settings.distance && live.resolvedNoiseAmount() == settings.amount);
QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); }));
break;
}
@@ -156,11 +152,15 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
case AdjustmentKind::exposure:
case AdjustmentKind::gradientMap:
case AdjustmentKind::grain:
case AdjustmentKind::addNoise:
case AdjustmentKind::gaussianBlur:
case AdjustmentKind::motionBlur:
case AdjustmentKind::blackWhite:
case AdjustmentKind::colorBalance: {
const FilterSettings reopened = session.filterEdit().value().settings;
QVERIFY(reopened.curves == saved.curves && reopened.exposure == saved.exposure() && reopened.gradientMap == saved.gradientMap()
&& reopened.grain == saved.grain() && reopened.blackWhite == saved.blackWhite() && reopened.colorBalance == saved.colorBalance());
&& reopened.grain == saved.grain() && reopened.blackWhite == saved.blackWhite() && reopened.colorBalance == saved.colorBalance()
&& reopened.radius == saved.gaussianRadius() && reopened.distance == saved.resolvedMotionDistance() && reopened.amount == saved.resolvedNoiseAmount());
session.updateFilter(FilterSettings(), true);
session.cancelFilter();
break;
@@ -202,7 +202,8 @@ void AdjustmentEditorTests::theEditorReadsOnlyWhatLiesBeneath()
QCOMPARE(layerWith(session, id).parentID, layerWith(session, green).parentID);
session.setAdjustmentEditingID(id);
QVERIFY(opened(session, id));
QVERIFY((input(session) == std::vector<int>{0, 255, 0, 255, 255, 0, 0, 255}));
const QImage read = session.levels().value().original.image();
QVERIFY(read.size() == QSize(2, 1) && read.pixel(0, 0) == qRgb(0, 255, 0) && read.pixel(1, 0) == qRgb(255, 0, 0));
// The editors preview nothing: the layer's settings are the preview.
LevelsSettings settings;
settings.ranges[0].outputWhite = 100;
+2 -2
View File
@@ -311,8 +311,8 @@ void AdjustmentLayerTests::everyKindAdjustsThroughItsSettings()
kinds[3].setExposure(ExposureSettings{.exposure = 1});
kinds[4].setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, false});
kinds[5].setGrain(GrainSettings{.amount = 80, .seed = 4});
kinds[7].setBlackWhite(BlackWhiteSettings{.reds = 100});
kinds[8].setColorBalance(ColorBalanceSettings{.midCyanRed = 60});
kinds[10].setBlackWhite(BlackWhiteSettings{.reds = 100});
kinds[11].setColorBalance(ColorBalanceSettings{.midCyanRed = 60});
for (const LayerAdjustment &adjustment : kinds) {
EditorSession s;
s.createDocument(2, 2);
+16 -6
View File
@@ -70,7 +70,7 @@ void AdjustmentPanelTests::theFooterAddsEachKindAndItsEditorOpens()
QCOMPARE(entries.size(), qsizetype(allAdjustmentKinds.size()));
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index)
QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index]).replace("&", "&&"));
QCOMPARE(entries[7]->text(), QString("Black && White"));
QCOMPARE(entries[10]->text(), QString("Black && White"));
session.createDocument(4, 4);
session.insert(whiteImage(4, 4));
QVERIFY(button.isEnabled());
@@ -103,11 +103,11 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
}
session.addMask();
NativeLayerList list(session);
list.resize(252, 500);
list.resize(252, 720);
list.show();
QVERIFY(QTest::qWaitForWindowActive(&list));
// Top first: the last kind added heads the list.
QCOMPARE(list.cells().size(), size_t(10));
QCOMPARE(list.cells().size(), size_t(13));
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index) {
LayerCell &row = *list.cells().at(allAdjustmentKinds.size() - 1 - index);
const QColor ink = row.palette().color(QPalette::WindowText);
@@ -120,6 +120,8 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
const std::pair<AdjustmentKind, LayerIcon> symbols[] = {{AdjustmentKind::hsv, LayerIcon::halfFilledCircle}, {AdjustmentKind::levels, LayerIcon::sliders},
{AdjustmentKind::curves, LayerIcon::curvePath}, {AdjustmentKind::exposure, LayerIcon::plusMinusCircle},
{AdjustmentKind::gradientMap, LayerIcon::paintPalette}, {AdjustmentKind::grain, LayerIcon::circleGrid},
{AdjustmentKind::addNoise, LayerIcon::dottedCircle}, {AdjustmentKind::gaussianBlur, LayerIcon::drop},
{AdjustmentKind::motionBlur, LayerIcon::wind},
{AdjustmentKind::invert, LayerIcon::rightHalfCircle}, {AdjustmentKind::blackWhite, LayerIcon::hatchedCircle},
{AdjustmentKind::colorBalance, LayerIcon::axes}};
QList<QImage> glyphs;
@@ -139,6 +141,12 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
const QImage upright = LayerIcons::pixmap(LayerIcon::curvePath, 36, Qt::black, 1).toImage();
QVERIFY(inked(curve, 24, 11) && !inked(curve, 11, 11));
QVERIFY(inked(upright, 7, 7) && !inked(upright, 29, 7));
// The drop fills; the wind strokes across; dots ring emptiness.
const auto glyph = [](AdjustmentKind kind) { return LayerIcons::adjustmentThumbnail(kind, Qt::black, 1).toImage(); };
QVERIFY(inked(glyph(AdjustmentKind::gaussianBlur), 18, 20) && !inked(glyph(AdjustmentKind::gaussianBlur), 11, 11));
QVERIFY(inked(glyph(AdjustmentKind::motionBlur), 11, 18) && inked(glyph(AdjustmentKind::motionBlur), 24, 18));
QVERIFY(!inked(glyph(AdjustmentKind::motionBlur), 16, 16));
QVERIFY(inked(glyph(AdjustmentKind::addNoise), 26, 18) && !inked(glyph(AdjustmentKind::addNoise), 18, 18));
// The masks: an adjustment's shows no link, the pixels' does.
QVERIFY(list.cells().front()->findChild<QToolButton *>("maskLink")->isHidden());
QVERIFY(!list.cells().back()->findChild<QToolButton *>("maskLink")->isHidden());
@@ -160,11 +168,11 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
QTest::keyClick(levels.findChild<QLineEdit *>("layerNameEditor"), Qt::Key_Escape);
QVERIFY(!session.renamingLayerID());
// Invert has no editor: its glyph renames, as Swift's row.
LayerCell &invert = *list.cells().at(allAdjustmentKinds.size() - 7);
LayerCell &invert = *list.cells().at(allAdjustmentKinds.size() - 10);
QTest::mouseDClick(&invert.thumbnail(), Qt::LeftButton);
QCOMPARE(session.activeLayerID(), std::optional(ids[6]));
QCOMPARE(session.activeLayerID(), std::optional(ids[9]));
QVERIFY(!session.adjustmentEditingID());
QCOMPARE(session.renamingLayerID(), std::optional(ids[6]));
QCOMPARE(session.renamingLayerID(), std::optional(ids[9]));
QCOMPARE(invert.findChild<QLabel *>("layerDimensions")->text(), QString("Adjustment · Double-click to edit"));
}
@@ -178,6 +186,8 @@ void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
const std::pair<const char *, const char *> entries[] = {{"newHueSaturationAdjustment", "Hue/Saturation…"}, {"newLevelsAdjustment", "Levels…"},
{"newCurvesAdjustment", "Curves…"}, {"newExposureAdjustment", "Exposure…"},
{"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"},
{"newAddNoiseAdjustment", "Add Noise…"}, {"newGaussianBlurAdjustment", "Gaussian Blur…"},
{"newMotionBlurAdjustment", "Motion Blur…"},
{"newInvertAdjustment", "Invert"}, {"newBlackWhiteAdjustment", "Black && White…"},
{"newColorBalanceAdjustment", "Color Balance…"}};
const QList<QAction *> listed = adjustments.menu()->actions();
+1 -1
View File
@@ -111,7 +111,7 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn()
painter.scale(2, 2);
bool called = false;
QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)");
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 63);
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63);
QVERIFY(!called);
AdjustmentSurface::draw(painter, [&](QPainter &target) {
called = true;
+375
View File
@@ -0,0 +1,375 @@
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore+Json.h"
#include "ProjectFixtures.h"
#include "RenderFixtures.h"
#include <QJsonArray>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
extern "C" {
#include "NoisePixels.h"
}
// Blur and noise adjustment layers: 1.2.3's three kinds.
namespace {
constexpr double notANumber = std::numeric_limits<double>::quiet_NaN();
ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name)
{
const QImage image = solid(width, height, premultiplied);
return ImportedImage(image, image, name);
}
bool refused(const QJsonObject &object)
{
try {
ManifestJson::adjustment(object);
} catch (const ProjectError &) {
return true;
}
return false;
}
// Saving and loading judge alike.
std::optional<ProjectError::Kind> stored(const ProjectSnapshot &snapshot)
{
QTemporaryDir root;
const std::optional<ProjectError::Kind> saving = projectError([&] { ProjectStore::save(snapshot, root.filePath("Saved.comp")); });
ProjectStore::save(twoLayers(), root.filePath("Read.comp"));
for (const auto &[id, pixels] : snapshot.images) {
if (!pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png"))
throw std::runtime_error("cannot write a fixture image");
}
overwrite(root.filePath("Read.comp/manifest.json"), snapshot.manifest.encoded());
if (saving != projectError([&] { ProjectStore::load(root.filePath("Read.comp")); }))
throw std::runtime_error("saving and loading judged the project apart");
return saving;
}
// White, black over the left half's top square, the adjustment.
QUuid blurred(EditorSession &session, const LayerAdjustment &adjustment)
{
const QSizeF size = session.document().value().size();
const int side = int(size.width()) / 2;
session.insert(filled(int(size.width()), int(size.height()), qRgba(255, 255, 255, 255), "White"), QPointF(size.width() / 2, size.height() / 2));
session.insert(filled(side, side, qRgba(0, 0, 0, 255), "Black"), QPointF(side / 2.0, side / 2.0));
session.addAdjustment(adjustment.kind);
session.setAdjustmentEditingID(std::nullopt);
const QUuid id = session.activeLayerID().value();
session.updateAdjustment(id, adjustment);
return id;
}
LayerAdjustment gaussian(double radius)
{
LayerAdjustment adjustment{AdjustmentKind::gaussianBlur};
adjustment.setGaussianRadius(radius);
return adjustment;
}
// A canvas this size at a zoom, no overlay.
void frame(EditorSession &session, CanvasView &canvas, QSize view, double zoom)
{
session.setShowsTransformControls(false);
canvas.resize(view);
session.viewport.resize(QSizeF(view), 1, session.document().value().size());
session.zoom(zoom);
}
int level(const QImage &image, int x, int y)
{
return qGray(image.pixel(x, y));
}
}
class BlurNoiseAdjustmentTests : public QObject {
Q_OBJECT
private slots:
void settingsFallBackToSwiftsDefaultsAndBound();
void theHaloCoversEachKindsReach();
void theManifestWritesTheKeysOnlyWhenSet();
void theStoreTakesTheKindsFromVersion9();
void eachNoiseLayerRollsItsOwnSeed();
void eachEditorWritesItsOwnSettingsAlone();
void theKindsRunTheirFiltersAtTheScale();
void noiseStaysOneFieldAcrossRegions();
void aScaledTargetBlursInItsOwnPixels();
void theCanvasBlursAtItsZoom();
void aPartialRepaintReadsTheHalo();
};
void BlurNoiseAdjustmentTests::settingsFallBackToSwiftsDefaultsAndBound()
{
LayerAdjustment adjustment{AdjustmentKind::levels};
QVERIFY(!adjustment.blurRadius && !adjustment.motionAngle && !adjustment.motionDistance && !adjustment.noiseAmount);
QVERIFY(!adjustment.noiseGaussian && !adjustment.noiseMonochromatic && !adjustment.noiseSeed);
QVERIFY(adjustment.gaussianRadius() == 10 && adjustment.resolvedMotionAngle() == 0 && adjustment.resolvedMotionDistance() == 10);
QVERIFY(adjustment.resolvedNoiseAmount() == 10 && !adjustment.resolvedNoiseGaussian() && !adjustment.resolvedNoiseMonochromatic());
QCOMPARE(adjustment.resolvedNoiseSeed(), 0u);
// Each setter fills its own optional.
adjustment.setGaussianRadius(3);
adjustment.setResolvedMotionAngle(-20);
adjustment.setResolvedMotionDistance(40);
adjustment.setResolvedNoiseAmount(70);
adjustment.setResolvedNoiseGaussian(true);
adjustment.setResolvedNoiseMonochromatic(true);
adjustment.setResolvedNoiseSeed(12);
QVERIFY(adjustment.blurRadius == 3.0 && adjustment.motionAngle == -20.0 && adjustment.motionDistance == 40.0 && adjustment.noiseAmount == 70.0);
QVERIFY(adjustment.noiseGaussian == true && adjustment.noiseMonochromatic == true && adjustment.noiseSeed == 12u);
QVERIFY(adjustment.isValid());
// Swift's ranges bind every kind, NaN included.
const std::pair<void (LayerAdjustment::*)(double), std::vector<std::pair<double, bool>>> bounds[] = {
{&LayerAdjustment::setGaussianRadius, {{0.1, true}, {250, true}, {0.09, false}, {250.5, false}, {notANumber, false}}},
{&LayerAdjustment::setResolvedMotionAngle, {{-90, true}, {90, true}, {-90.5, false}, {90.5, false}, {notANumber, false}}},
{&LayerAdjustment::setResolvedMotionDistance, {{1, true}, {2000, true}, {0.5, false}, {2000.5, false}, {notANumber, false}}},
{&LayerAdjustment::setResolvedNoiseAmount, {{0.1, true}, {400, true}, {0.05, false}, {400.5, false}, {notANumber, false}}}};
for (const auto &[set, values] : bounds) {
for (const auto &[value, valid] : values) {
LayerAdjustment bounded{AdjustmentKind::hsv};
(bounded.*set)(value);
QVERIFY2(bounded.isValid() == valid, qPrintable(QString::number(value)));
}
}
}
void BlurNoiseAdjustmentTests::theHaloCoversEachKindsReach()
{
// Swift's samplingMargin: three sigmas plus two, half streak plus two.
QCOMPARE(gaussian(4).samplingMargin(), 14.0);
LayerAdjustment motion{AdjustmentKind::motionBlur};
motion.setResolvedMotionDistance(20);
QCOMPARE(motion.samplingMargin(), 12.0);
for (const AdjustmentKind kind : allAdjustmentKinds) {
if (kind != AdjustmentKind::gaussianBlur && kind != AdjustmentKind::motionBlur)
QCOMPARE(LayerAdjustment{kind}.samplingMargin(), 0.0);
}
}
void BlurNoiseAdjustmentTests::theManifestWritesTheKeysOnlyWhenSet()
{
QCOMPARE(ManifestJson::encoded(LayerAdjustment{AdjustmentKind::addNoise}).keys(),
(QStringList{"colorize", "curves", "hue", "kind", "levels", "lightness", "saturation"}));
LayerAdjustment set{AdjustmentKind::motionBlur};
set.setGaussianRadius(2.5);
set.setResolvedMotionAngle(-45);
set.setResolvedMotionDistance(30);
set.setResolvedNoiseAmount(25);
set.setResolvedNoiseGaussian(true);
set.setResolvedNoiseMonochromatic(false);
set.setResolvedNoiseSeed(4'294'967'295u);
const QJsonObject object = ManifestJson::encoded(set);
QCOMPARE(object.value("kind").toString(), QString("Motion Blur"));
QVERIFY(object.value("blurRadius") == 2.5 && object.value("motionAngle") == -45 && object.value("motionDistance") == 30);
QVERIFY(object.value("noiseAmount") == 25 && object.value("noiseGaussian") == true && object.value("noiseMonochromatic") == false);
QCOMPARE(object.value("noiseSeed").toInteger(), qint64(4'294'967'295));
QVERIFY(ManifestJson::adjustment(object) == set);
// Absent or null is nil; wrong types and seeds refuse.
for (const char *key : {"blurRadius", "motionAngle", "motionDistance", "noiseAmount", "noiseGaussian", "noiseMonochromatic", "noiseSeed"}) {
QJsonObject changed = object;
changed.insert(QLatin1String(key), QJsonValue::Null);
QVERIFY2(!refused(changed) && ManifestJson::adjustment(changed) != set, key);
changed.remove(QLatin1String(key));
QVERIFY2(!refused(changed), key);
changed.insert(QLatin1String(key), QJsonArray());
QVERIFY2(refused(changed), key);
}
for (const QJsonValue &seed : {QJsonValue(-1), QJsonValue(qint64(4'294'967'296)), QJsonValue(1.5)}) {
QJsonObject changed = object;
changed.insert("noiseSeed", seed);
QVERIFY(refused(changed));
}
}
void BlurNoiseAdjustmentTests::theStoreTakesTheKindsFromVersion9()
{
for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) {
ProjectSnapshot snapshot = twoLayers();
snapshot.manifest.layers[1].adjustment = LayerAdjustment{kind};
QCOMPARE(stored(snapshot), std::nullopt);
snapshot.manifest.version = 8;
QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid));
// Invalid settings refuse it at any version.
snapshot.manifest.version = 9;
snapshot.manifest.layers[1].adjustment->setGaussianRadius(300);
QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid));
}
// The rule reads the kind: older kinds may carry keys.
ProjectSnapshot older = twoLayers();
older.manifest.version = 7;
older.manifest.layers[1].adjustment = LayerAdjustment{AdjustmentKind::levels};
older.manifest.layers[1].adjustment->setGaussianRadius(5);
QCOMPARE(stored(older), std::nullopt);
}
void BlurNoiseAdjustmentTests::eachNoiseLayerRollsItsOwnSeed()
{
EditorSession session;
session.createDocument(4, 4);
session.addAdjustment(AdjustmentKind::addNoise);
const LayerAdjustment first = session.activeLayer().value().adjustment.value();
session.setAdjustmentEditingID(std::nullopt);
session.addAdjustment(AdjustmentKind::addNoise);
const LayerAdjustment second = session.activeLayer().value().adjustment.value();
QVERIFY(first.noiseSeed && second.noiseSeed && *first.noiseSeed != *second.noiseSeed);
// Only noise rolls one; the rest keep their defaults.
session.setAdjustmentEditingID(std::nullopt);
session.addAdjustment(AdjustmentKind::gaussianBlur);
QVERIFY(session.activeLayer().value().adjustment.value() == LayerAdjustment{AdjustmentKind::gaussianBlur});
}
void BlurNoiseAdjustmentTests::eachEditorWritesItsOwnSettingsAlone()
{
for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) {
EditorSession session;
session.createDocument(4, 4);
session.insert(filled(4, 4, qRgba(255, 255, 255, 255), "White"));
session.addAdjustment(kind);
const QUuid id = session.activeLayerID().value();
// Every field set: the panel reads them all.
LayerAdjustment preset = session.activeLayer().value().adjustment.value();
preset.setGaussianRadius(20);
preset.setResolvedMotionAngle(45);
preset.setResolvedMotionDistance(30);
preset.setResolvedNoiseAmount(50);
preset.setResolvedNoiseGaussian(true);
preset.setResolvedNoiseMonochromatic(true);
session.updateAdjustment(id, preset);
bool open = false;
session.beginAdjustmentEditing(id, [&open] { open = true; });
QTRY_VERIFY(open);
const FilterEdit &edit = session.filterEdit().value();
QVERIFY(edit.kind == filterKind(kind));
QVERIFY(edit.settings.radius == 20 && edit.settings.angle == 45 && edit.settings.distance == 30 && edit.settings.amount == 50);
QVERIFY(edit.settings.gaussian && edit.settings.monochromatic);
session.updateFilter(FilterSettings{.radius = 5, .angle = -30, .distance = 12, .amount = 70}, true);
// The kind writes its own; the others keep the preset.
const LayerAdjustment live = session.activeLayer().value().adjustment.value();
const bool blur = kind == AdjustmentKind::gaussianBlur, motion = kind == AdjustmentKind::motionBlur, noise = !blur && !motion;
QCOMPARE(live.blurRadius, std::optional(blur ? 5.0 : 20.0));
QCOMPARE(live.motionAngle, std::optional(motion ? -30.0 : 45.0));
QCOMPARE(live.motionDistance, std::optional(motion ? 12.0 : 30.0));
QCOMPARE(live.noiseAmount, std::optional(noise ? 70.0 : 50.0));
QCOMPARE(live.noiseGaussian, std::optional(!noise));
QCOMPARE(live.noiseMonochromatic, std::optional(!noise));
// The seed stays the layer's own.
QCOMPARE(live.noiseSeed.has_value(), noise);
session.cancelFilter();
}
}
void BlurNoiseAdjustmentTests::theKindsRunTheirFiltersAtTheScale()
{
QImage image = BrushRaster::context(12, 12, false);
image.fill(0);
QPainter(&image).fillRect(QRect(3, 3, 6, 6), QColor(200, 100, 50));
const auto filtered = [&image](FilterKind kind, const FilterSettings &settings, quint32 seed = 0) {
return PixelFilter::run(FilterJob{kind, image, settings, 1, std::nullopt, QTransform(), seed});
};
// At scale two a blur reads twice the radius.
QCOMPARE(gaussian(1.5).apply(image, std::nullopt, 2), filtered(FilterKind::gaussianBlur, FilterSettings{.radius = 3}));
LayerAdjustment motion{AdjustmentKind::motionBlur};
motion.setResolvedMotionAngle(30);
motion.setResolvedMotionDistance(4);
QCOMPARE(motion.apply(image, std::nullopt, 2), filtered(FilterKind::motionBlur, FilterSettings{.angle = 30, .distance = 8}));
LayerAdjustment noise{AdjustmentKind::addNoise};
noise.setResolvedNoiseAmount(60);
noise.setResolvedNoiseGaussian(true);
noise.setResolvedNoiseMonochromatic(true);
noise.setResolvedNoiseSeed(77);
QCOMPARE(noise.apply(image), filtered(FilterKind::addNoise, FilterSettings{.amount = 60, .gaussian = true, .monochromatic = true}, 77));
QVERIFY(noise.apply(image) != image);
}
void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions()
{
QImage gray = BrushRaster::context(8, 8, false);
gray.fill(QColor(128, 128, 128));
LayerAdjustment noise{AdjustmentKind::addNoise};
noise.setResolvedNoiseAmount(50);
noise.setResolvedNoiseSeed(3);
// A region's pixels are the whole field's there.
const QImage whole = noise.apply(gray, QRectF(10, 20, 8, 8));
QCOMPARE(noise.apply(gray.copy(2, 3, 4, 4), QRectF(12, 23, 4, 4)), whole.copy(2, 3, 4, 4));
// The kernel's origin, in the image's own pixels.
QImage expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40);
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected);
// A negative origin floors, as Swift rounds down.
expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21);
QCOMPARE(noise.apply(gray, QRectF(-10.5, -20.5, 8, 8)), expected);
// Without a region, the origin is zero.
expected = gray.copy();
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 0, 0);
QCOMPARE(noise.apply(gray), expected);
}
void BlurNoiseAdjustmentTests::aScaledTargetBlursInItsOwnPixels()
{
// Drawn twice as large, the blur doubles in pixels.
EditorSession session;
session.createDocument(16, 16);
blurred(session, gaussian(1.5));
QImage twice = BrushRaster::context(32, 32, false);
twice.fill(0);
{
QPainter painter(&twice);
painter.scale(2, 2);
session.drawLiveComposite(session.document().value(), painter);
}
session.toggleLayerVisibility(session.activeLayerID().value());
QImage plain = BrushRaster::context(32, 32, false);
plain.fill(0);
{
QPainter painter(&plain);
painter.scale(2, 2);
session.drawLiveComposite(session.document().value(), painter);
}
QCOMPARE(twice, gaussian(1.5).apply(plain, QRectF(0, 0, 16, 16), 2));
}
void BlurNoiseAdjustmentTests::theCanvasBlursAtItsZoom()
{
// At half size radius 8 spreads 4 view points.
EditorSession session;
session.createDocument(200, 100);
CanvasView canvas(session);
blurred(session, gaussian(8));
frame(session, canvas, QSize(200, 100), 0.5);
QCOMPARE(session.viewport.viewPoint(QPointF(100, 50), QSizeF(200, 100)), QPointF(100, 50));
const QImage shot = canvas.grab().toImage();
// 0.875 sigma out 19% of the black spreads; 1.875, 3%.
const int near = level(shot, 103, 50), far = level(shot, 107, 50);
QVERIFY2(near > 195 && near < 220, qPrintable(QString::number(near)));
QVERIFY2(far > 240, qPrintable(QString::number(far)));
}
void BlurNoiseAdjustmentTests::aPartialRepaintReadsTheHalo()
{
// A dirty rect by the edge sees pixels past it.
for (const double zoom : {1.0, 1.5, 4.0}) {
EditorSession session;
session.createDocument(60, 60);
CanvasView canvas(session);
blurred(session, gaussian(3));
// A hair-thin blur above: the widest margin must win.
session.addAdjustment(AdjustmentKind::gaussianBlur);
session.setAdjustmentEditingID(std::nullopt);
session.updateAdjustment(session.activeLayerID().value(), gaussian(0.2));
frame(session, canvas, QSize(240, 240), zoom);
const QImage full = canvas.grab().toImage();
const QPointF edge = session.viewport.viewPoint(QPointF(30, 15), QSizeF(60, 60));
const QRect dirty(int(edge.x()) - 3, int(edge.y()) - 3, 6, 6);
QImage part(canvas.size(), QImage::Format_ARGB32_Premultiplied);
part.fill(0);
canvas.render(&part, dirty.topLeft(), QRegion(dirty));
for (int y = dirty.top(); y <= dirty.bottom(); ++y) {
for (int x = dirty.left(); x <= dirty.right(); ++x)
QVERIFY2(part.pixel(x, y) == full.pixel(x, y), qPrintable(QStringLiteral("%1 at %2, %3").arg(zoom).arg(x).arg(y)));
}
}
}
QTEST_MAIN(BlurNoiseAdjustmentTests)
#include "BlurNoiseAdjustmentTests.moc"
+10 -6
View File
@@ -36,11 +36,11 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""},
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
{"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""},
{"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""}, {"newInvertAdjustment", ""}, {"newBlackWhiteAdjustment", ""},
{"newColorBalanceAdjustment", ""},
{"newColorBalanceAdjustment", ""}, {"newAddNoiseAdjustment", ""}, {"newGaussianBlurAdjustment", ""}, {"newMotionBlurAdjustment", ""},
};
for (const auto &[name, key] : keys)
QCOMPARE(bar.action(name).shortcut().toString(), QString::fromLatin1(key));
@@ -54,7 +54,11 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QCOMPARE(menus[1]->actions().size(), 13);
QCOMPARE(menus[2]->actions().size(), 16);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[4]->actions().size(), 15);
QCOMPARE(menus[4]->actions().size(), 16);
// Swift's Trim… follows Image Size….
const QList<QAction *> image = menus[4]->actions();
QCOMPARE(image.indexOf(&bar.action("trim")), image.indexOf(&bar.action("imageSize")) + 1);
QCOMPARE(bar.action("trim").text(), QString("Trim…"));
// Swift's Image menu: Black & White and Color Balance first.
QStringList adjustments;
for (int index = 3; index < 8; ++index)
@@ -433,7 +437,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
{
Bar bar;
bar.window.show();
const char *entries[] = {"canvasSize", "imageSize", "flipCanvasHorizontal", "flipCanvasVertical"};
const char *entries[] = {"canvasSize", "imageSize", "trim", "flipCanvasHorizontal", "flipCanvasVertical"};
for (const char *name : entries)
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().createDocument(100, 50);
@@ -446,7 +450,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().setIsProjectBusy(false);
bar.session().selectTool(NavigationTool::crop);
QVERIFY(bar.action("canvasSize").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
QVERIFY(bar.action("canvasSize").isEnabled() && bar.action("trim").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
bar.session().selectTool(NavigationTool::move);
bar.action("flipCanvasHorizontal").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 10));
@@ -454,7 +458,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
bar.action("flipCanvasVertical").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39));
// The size entries open their sheets over the window.
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size")}) {
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size"), std::pair("trim", "Trim")}) {
bar.action(name).trigger();
auto *sheet = bar.window.findChild<QDialog *>();
QVERIFY(sheet && sheet->isVisible());
+2 -1
View File
@@ -119,7 +119,7 @@ void EffectsPanelTests::theFooterAddsEachEffectAndOpensItsPanel()
QStringList names;
for (const QAction *entry : button.menu()->actions())
names << entry->text();
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…", "Outer Glow…"}));
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…", "Outer Glow…", "Inner Glow…"}));
QVERIFY(button.isEnabled());
button.menu()->actions()[0]->trigger();
QVERIFY((editor.session.effectsEditing() == LayerEffectSelection{editor.layer, LayerEffectKind::stroke}));
@@ -292,6 +292,7 @@ void EffectsPanelTests::eachKindHasSwiftsControls_data()
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow")
<< QStringList{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:50", "Blur:0:500:100"};
QTest::newRow("outer glow") << LayerEffectKind::outerGlow << QString("Outer Glow") << QStringList{"Size:0:500:100", "Opacity:0:100:100"};
QTest::newRow("inner glow") << LayerEffectKind::innerGlow << QString("Inner Glow") << QStringList{"Size:0:500:100", "Opacity:0:100:100"};
}
void EffectsPanelTests::eachKindHasSwiftsControls()
+2
View File
@@ -320,6 +320,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5};
effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 30, .blur = 12, .red = 0, .green = 0, .blue = 0, .opacity = 0.7};
effects.outerGlow = OuterGlowEffect{.size = 5, .red = 0, .green = 0, .blue = 1, .opacity = 0.6};
effects.innerGlow = InnerGlowEffect{.size = 40, .red = 1, .green = 0, .blue = 1, .opacity = 0.8};
const QImage image = noise(6000, 100, 7);
const ImageLayer layer = layerOf(image, effects);
const Landings landings;
@@ -335,6 +336,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
effects.innerShadow->distance *= factor;
effects.innerShadow->blur *= factor;
effects.outerGlow->size *= factor;
effects.innerGlow->size *= factor;
QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image);
}
+1 -1
View File
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
const QString path = root.filePath("Dimmed.comp");
ProjectStore::save(session.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(8));
QCOMPARE(loaded.manifest.version, qint64(9));
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
if (record.isGroup == true)
QCOMPARE(record.opacity, std::optional(0.5));
+1 -1
View File
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
session.addBlankLayer();
const QUuid child = session.activeLayerID().value();
const ProjectSnapshot snapshot = session.projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(8));
QCOMPARE(snapshot.manifest.version, qint64(9));
const auto record = [](const ProjectSnapshot &from, QUuid id) {
for (const ProjectLayerRecord &layer : from.manifest.layers) {
if (layer.id == id)
+3 -3
View File
@@ -258,10 +258,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
QCOMPARE(invalid.version, std::nullopt);
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
const ProjectError version = ProjectError::unsupportedVersion(9);
const ProjectError version = ProjectError::unsupportedVersion(10);
QCOMPARE(version.kind, ProjectError::Kind::version);
QCOMPARE(version.version, std::optional<int>(9));
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–8."));
QCOMPARE(version.version, std::optional<int>(10));
QCOMPARE(QString(version.what()), QString("This project uses format version 10. This app supports versions 1–9."));
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
const ProjectError missingImage(ProjectError::Kind::missingImage);
+1 -1
View File
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
session.addGuide(first);
session.addGuide(second);
ProjectSnapshot snapshot = session.projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(8));
QCOMPARE(snapshot.manifest.version, qint64(9));
QCOMPARE(snapshot.manifest.guides.value(), (std::vector<CanvasGuide>{first, second}));
// Swift's keys: an array of id, axis and position.
const QByteArray json = snapshot.manifest.encoded();
+300
View File
@@ -0,0 +1,300 @@
#include "DialogDesk.h"
#include "Document/ImageTrim.h"
#include "IO/ProjectController.h"
#include "UI/TrimSheet.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QDialog>
#include <QLabel>
#include <QPushButton>
#include <QRadioButton>
#include <QtTest>
// Swift's ImageTrimTests, then the sheet, the controller and the menu.
namespace {
struct Colour {
int r, g, b, a;
};
// Premultiplied RGBA, painted pixel by pixel.
QImage painted(int width, int height, const std::function<Colour(int, int)> &painter)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < height; ++y) {
uchar *row = image.scanLine(y);
for (int x = 0; x < width; ++x) {
const Colour colour = painter(x, y);
row[x * 4] = uchar(colour.r), row[x * 4 + 1] = uchar(colour.g), row[x * 4 + 2] = uchar(colour.b), row[x * 4 + 3] = uchar(colour.a);
}
}
return image;
}
TrimOptions edges(TrimBasedOn basedOn, bool top, bool bottom, bool left, bool right)
{
return TrimOptions{.basedOn = basedOn, .top = top, .bottom = bottom, .left = left, .right = right};
}
struct Desk {
EditorSession session;
ProjectController controller{session};
QWidget window;
bool done = false;
Desk()
{
session.createDocument(100, 100);
session.insert(ImportedImage(painted(40, 40, [](int, int) { return Colour{200, 50, 50, 255}; }), QImage(), "Square"), QPointF(40, 50));
window.resize(600, 400);
window.show();
controller.window = &window;
}
QDialog &dialog()
{
auto *shown = window.findChild<QDialog *>();
if (!shown)
throw std::runtime_error("no sheet shown");
return *shown;
}
std::function<void()> finished()
{
done = false;
return [this] { done = true; };
}
};
}
class ImageTrimTests : public QObject {
Q_OBJECT
private slots:
void calculateTrimRectTransparentPixels();
void calculateTrimRectTopLeftPixelColor();
void calculateTrimRectBottomRightPixelColor();
void uniformImageReturnsNil();
void trimImageDirectly();
void sessionTrimIntegrationAndUndo();
void toleranceAndAlphaDecideAMatch();
void theSheetSaysSwiftsWordsAndReturnsItsChoice();
void theControllerTrimsThroughTheSheetAsOneStep();
void nothingToTrimOrACancelChangesNothing();
void aTrimThatFailsIsExplained();
};
void ImageTrimTests::calculateTrimRectTransparentPixels()
{
// Content x 2..<17, y 4..<15, clear around it.
const QImage image = painted(20, 20, [](int x, int y) {
return x >= 2 && x < 17 && y >= 4 && y < 15 ? Colour{255, 0, 0, 255} : Colour{0, 0, 0, 0};
});
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, true, true)), std::optional(QRect(2, 4, 15, 11)));
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, false, false)), std::optional(QRect(0, 4, 20, 11)));
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, true, true)), std::optional(QRect(2, 0, 15, 20)));
// One edge alone, each.
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, true, false, false)), std::optional(QRect(0, 0, 20, 15)));
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, true)), std::optional(QRect(0, 0, 17, 20)));
// No edge chosen trims nothing.
QVERIFY(!edges(TrimBasedOn::transparentPixels, false, false, false, false).trimsAny());
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, false)), std::nullopt);
}
void ImageTrimTests::calculateTrimRectTopLeftPixelColor()
{
// A blue border of three around yellow.
const QImage image = painted(16, 16, [](int x, int y) {
return x >= 3 && x < 13 && y >= 3 && y < 13 ? Colour{255, 255, 0, 255} : Colour{0, 0, 255, 255};
});
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(3, 3, 10, 10)));
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, false, false, false)), std::optional(QRect(0, 3, 16, 13)));
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, false, false, true, false)), std::optional(QRect(3, 0, 13, 16)));
// A staircase: the widest reach comes from middle rows.
const QImage stairs = painted(8, 3, [](int x, int y) {
const int from = std::array{1, 5, 3}[size_t(y)];
return x >= from && x < from + 2 ? Colour{255, 255, 255, 255} : Colour{0, 0, 0, 255};
});
QCOMPARE(ImageTrim::calculateTrimRect(stairs, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(1, 0, 6, 3)));
}
void ImageTrimTests::calculateTrimRectBottomRightPixelColor()
{
// Magenta along the bottom and right four, cyan elsewhere.
const QImage image = painted(16, 16, [](int x, int y) { return x >= 12 || y >= 12 ? Colour{255, 0, 255, 255} : Colour{0, 255, 255, 255}; });
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(0, 0, 12, 12)));
// Matches count from a row's ends alone.
const QImage holed = painted(8, 3, [](int x, int y) { return y == 1 && (x == 1 || x == 6) ? Colour{9, 9, 9, 255} : Colour{0, 0, 0, 255}; });
QCOMPARE(ImageTrim::calculateTrimRect(holed, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(1, 1, 6, 1)));
}
void ImageTrimTests::uniformImageReturnsNil()
{
const QImage solid = painted(8, 8, [](int, int) { return Colour{100, 150, 200, 255}; });
QCOMPARE(ImageTrim::calculateTrimRect(solid, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::nullopt);
const QImage clear = painted(8, 8, [](int, int) { return Colour{0, 0, 0, 0}; });
QCOMPARE(ImageTrim::calculateTrimRect(clear, TrimOptions()), std::nullopt);
}
// Swift's trimImage, uncalled in the app: the rect it crops.
void ImageTrimTests::trimImageDirectly()
{
const QImage image = painted(10, 10, [](int x, int y) { return x >= 2 && x < 8 && y >= 2 && y < 8 ? Colour{255, 128, 0, 255} : Colour{0, 0, 0, 0}; });
QCOMPARE(ImageTrim::calculateTrimRect(image, TrimOptions()), std::optional(QRect(2, 2, 6, 6)));
}
// Swift's session trim, uncalled: the controller's two steps.
void ImageTrimTests::sessionTrimIntegrationAndUndo()
{
Desk desk;
EditorSession &session = desk.session;
QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30));
const std::optional<ProjectSnapshot> trimmed = ImageTrim::trim(session.projectSnapshot().value(), TrimOptions());
session.applyDocumentSize(trimmed.value(), QStringLiteral("Trim"));
QVERIFY(session.document().value().width == 40 && session.document().value().height == 40);
QCOMPARE(session.activeLayer().value().origin(), QPointF(0, 0));
QCOMPARE(session.history.undoName(), QString("Trim"));
session.undo();
QVERIFY(session.document().value().width == 100 && session.document().value().height == 100);
QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30));
// A canvas with nothing to cut comes back whole.
session.createDocument(4, 4);
session.insert(ImportedImage(painted(4, 4, [](int, int) { return Colour{1, 2, 3, 255}; }), QImage(), "Full"));
// The snapshot comes back whole, effects and all.
session.setEffects(LayerEffects{.stroke = StrokeEffect()});
const ProjectSnapshot full = session.projectSnapshot().value();
QVERIFY(full.manifest.layers[0].effects);
QVERIFY(ImageTrim::trim(full, TrimOptions()).value().manifest.encoded() == full.manifest.encoded());
// A clear canvas has nothing left.
session.createDocument(4, 4);
QCOMPARE(ImageTrim::trim(session.projectSnapshot().value(), TrimOptions()).has_value(), false);
}
void ImageTrimTests::toleranceAndAlphaDecideAMatch()
{
// A frame just off the corner, then a half-clear one.
const QImage image = painted(6, 6, [](int x, int y) {
if (x == 0 && y == 0)
return Colour{10, 10, 10, 255};
if (x == 0 || y == 0)
return Colour{12, 9, 10, 255};
if (x == 1 || y == 1)
return Colour{10, 10, 10, 128};
return Colour{200, 0, 0, 255};
});
TrimOptions options = edges(TrimBasedOn::topLeftPixelColor, true, false, true, false);
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(0, 0, 6, 6)));
options.tolerance = 2;
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(1, 1, 5, 5)));
// Alpha is a channel like the others.
options.tolerance = 127;
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(2, 2, 4, 4)));
QCOMPARE(rawValue(TrimBasedOn::transparentPixels), QString("Transparent Pixels"));
QCOMPARE(rawValue(TrimBasedOn::topLeftPixelColor), QString("Top Left Pixel Color"));
QCOMPARE(rawValue(TrimBasedOn::bottomRightPixelColor), QString("Bottom Right Pixel Color"));
QVERIFY(TrimOptions() == edges(TrimBasedOn::transparentPixels, true, true, true, true) && TrimOptions().tolerance == 0);
}
void ImageTrimTests::theSheetSaysSwiftsWordsAndReturnsItsChoice()
{
std::optional<std::optional<TrimOptions>> answer;
TrimSheet sheet([&answer](std::optional<TrimOptions> options) { answer = options; });
sheet.show();
QCOMPARE(sheet.width(), 320);
QStringList words;
for (const QLabel *label : sheet.findChildren<QLabel *>())
words << label->text();
QCOMPARE(words, (QStringList{"Trim", "Based On", "Trim Away"}));
QCOMPARE(sheet.findChildren<QLabel *>().at(0)->font().pixelSize(), 17);
QVERIFY(sheet.findChildren<QLabel *>().at(0)->font().bold() && sheet.findChildren<QLabel *>().at(1)->font().weight() == QFont::DemiBold);
// Swift's defaults: transparent pixels, every edge.
for (const TrimBasedOn option : allTrimBasedOn) {
auto *radio = sheet.findChild<QRadioButton *>(QStringLiteral("trimBasedOn%1").arg(int(option)));
QVERIFY(radio && radio->text() == rawValue(option) && radio->isChecked() == (option == TrimBasedOn::transparentPixels));
}
auto &ok = *sheet.findChild<QPushButton *>("trimOK");
QVERIFY(ok.isDefault() && ok.isEnabled() && !sheet.findChild<QPushButton *>("trimCancel")->autoDefault());
for (const char *edge : {"trimTop", "trimBottom", "trimLeft", "trimRight"})
QVERIFY(sheet.findChild<QCheckBox *>(edge)->isChecked());
// OK rests only while every edge is off.
for (const char *edge : {"trimTop", "trimBottom", "trimLeft"})
sheet.findChild<QCheckBox *>(edge)->click();
QVERIFY(ok.isEnabled());
sheet.findChild<QCheckBox *>("trimRight")->click();
QVERIFY(!ok.isEnabled());
sheet.findChild<QCheckBox *>("trimBottom")->click();
sheet.findChild<QRadioButton *>("trimBasedOn2")->click();
ok.click();
QVERIFY(answer.value() == edges(TrimBasedOn::bottomRightPixelColor, false, true, false, false));
sheet.findChild<QPushButton *>("trimCancel")->click();
QVERIFY(answer && !answer->has_value());
}
void ImageTrimTests::theControllerTrimsThroughTheSheetAsOneStep()
{
Desk desk;
const int steps = desk.session.history.undoCount();
desk.controller.trim(desk.finished());
QVERIFY(desk.session.isProjectBusy());
QCOMPARE(desk.dialog().windowTitle(), QString("Trim"));
QVERIFY(desk.dialog().isVisible() && desk.dialog().windowModality() == Qt::WindowModal);
QVERIFY(desk.dialog().findChild<TrimSheet *>());
// Inside the dialog Cancel still defaults to nothing.
QVERIFY(!desk.dialog().findChild<QPushButton *>("trimCancel")->autoDefault());
// Return is OK: the canvas shrinks to the square.
QTest::keyClick(&desk.dialog(), Qt::Key_Return);
QTRY_VERIFY(desk.done);
QVERIFY(desk.session.document().value().width == 40 && desk.session.document().value().height == 40);
QCOMPARE(desk.session.history.undoCount(), steps + 1);
QCOMPARE(desk.session.history.undoName(), QString("Trim"));
QVERIFY(!desk.session.isProjectBusy());
QTRY_VERIFY(!desk.window.findChild<QDialog *>());
}
void ImageTrimTests::nothingToTrimOrACancelChangesNothing()
{
Desk desk;
const CanvasDocument before = desk.session.document().value();
const int steps = desk.session.history.undoCount();
desk.controller.trim(desk.finished());
QTest::keyClick(&desk.dialog(), Qt::Key_Escape);
QTRY_VERIFY(desk.done);
QVERIFY(desk.session.document().value() == before && desk.session.history.undoCount() == steps && !desk.session.isProjectBusy());
// Everything matches the corner: no alert, no step.
DialogDesk alerts;
desk.session.createDocument(8, 8);
desk.session.insert(ImportedImage(painted(8, 8, [](int, int) { return Colour{5, 5, 5, 255}; }), QImage(), "Flat"));
const CanvasDocument flat = desk.session.document().value();
const int flatSteps = desk.session.history.undoCount();
desk.controller.trim(desk.finished());
desk.dialog().findChild<QRadioButton *>("trimBasedOn1")->click();
desk.dialog().findChild<QPushButton *>("trimOK")->click();
QTRY_VERIFY(desk.done);
QVERIFY(desk.session.document().value() == flat && desk.session.history.undoCount() == flatSteps);
QVERIFY(alerts.seen.isEmpty() && !desk.session.isProjectBusy());
// Busy, or without a window, no sheet opens.
desk.session.setIsProjectBusy(true);
desk.controller.trim(desk.finished());
QTRY_VERIFY(desk.done);
QVERIFY(!desk.window.findChild<QDialog *>());
desk.session.setIsProjectBusy(false);
desk.controller.window = nullptr;
desk.controller.trim(desk.finished());
QTRY_VERIFY(desk.done);
QVERIFY(!desk.session.isProjectBusy());
}
void ImageTrimTests::aTrimThatFailsIsExplained()
{
Desk desk;
// Past the render's hundred megapixels, the trim cannot look.
desk.session.createDocument(20'000, 10'000);
const CanvasDocument before = desk.session.document().value();
DialogDesk alerts;
alerts.replies = {"OK"};
alerts.note = [&] { return QStringLiteral("busy %1").arg(desk.session.isProjectBusy()); };
desk.controller.trim(desk.finished());
desk.dialog().findChild<QPushButton *>("trimOK")->click();
QTRY_VERIFY(desk.done);
QCOMPARE(alerts.seen, (QStringList{QStringLiteral("alert|2|Couldn’t trim image|%1|OK|busy 1")
.arg(QString::fromUtf8(ExportError(ExportError::Kind::tooLarge).what()))}));
QVERIFY(desk.session.document().value() == before && !desk.session.isProjectBusy());
}
QTEST_MAIN(ImageTrimTests)
#include "ImageTrimTests.moc"
+217
View File
@@ -0,0 +1,217 @@
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore+Json.h"
#include <QPainter>
#include <QtTest>
// Swift's InnerGlowTests: the glow's model, drawing and export.
namespace {
// Swift's solidSquare: opaque, one colour.
QImage square(int size, const QColor &colour)
{
QImage image(size, size, QImage::Format_RGBA8888_Premultiplied);
image.fill(colour);
return image;
}
QColor at(const LayerEffectsRenderer::Rendered &rendered, int x, int y)
{
const int inset = int(rendered.inset);
return rendered.image.pixelColor(inset + x, inset + y);
}
LayerEffects glowing(double size, double red, double green, double blue, double opacity)
{
LayerEffects effects;
effects.innerGlow = InnerGlowEffect{.enabled = true, .size = size, .red = red, .green = green, .blue = blue, .opacity = opacity};
return effects;
}
}
class InnerGlowTests : public QObject {
Q_OBJECT
private slots:
void innerGlowDefaultsAndValidation();
void innerGlowCodableRoundTrip();
void innerGlowBackwardCompatibility();
void layerEffectsIntegration();
void innerGlowRendersInsideSourceWithoutBoundsExpansion();
void innerGlowRendersAroundTextGlyphs();
void itSitsOverTheOverlayAndUnderTheInnerShadow();
void innerGlowPreservedInExport();
void theSessionAddsCopiesAndCancelsIt();
};
void InnerGlowTests::innerGlowDefaultsAndValidation()
{
const InnerGlowEffect glow;
QVERIFY(glow.isEnabled() && glow.isValid());
QCOMPARE(glow.size, 10.0);
QCOMPARE(glow.opacity, 0.75);
QVERIFY(glow.color() == (PaletteColor{1, 1, 1}));
for (const auto &broken : std::vector<std::function<void(InnerGlowEffect &)>>{
[](InnerGlowEffect &e) { e.size = -1; }, [](InnerGlowEffect &e) { e.size = 500.5; }, [](InnerGlowEffect &e) { e.size = qQNaN(); },
[](InnerGlowEffect &e) { e.opacity = 1.5; }, [](InnerGlowEffect &e) { e.opacity = -0.1; }, [](InnerGlowEffect &e) { e.red = 2; },
[](InnerGlowEffect &e) { e.green = -0.1; }, [](InnerGlowEffect &e) { e.blue = qInf(); }}) {
InnerGlowEffect changed = glow;
broken(changed);
QVERIFY(!changed.isValid());
LayerEffects effects;
effects.innerGlow = changed;
QVERIFY(!effects.isValid());
}
InnerGlowEffect edge = glow;
edge.size = 500;
edge.opacity = 1;
QVERIFY(edge.isValid());
}
void InnerGlowTests::innerGlowCodableRoundTrip()
{
const LayerEffects original = glowing(25, 1, 0.5, 0.2, 0.85);
const QJsonObject encoded = ManifestJson::encoded(original);
const QJsonObject glow = encoded.value("innerGlow").toObject();
QCOMPARE(glow.keys(), (QStringList{"blue", "enabled", "green", "opacity", "red", "size"}));
QVERIFY(glow.value("size") == 25 && glow.value("opacity") == 0.85 && glow.value("green") == 0.5);
QCOMPARE(ManifestJson::effects(encoded), original);
// Absent `enabled` stays absent, read as shown.
LayerEffects shown = original;
shown.innerGlow->enabled = std::nullopt;
QVERIFY(!ManifestJson::encoded(shown).value("innerGlow").toObject().contains("enabled"));
QCOMPARE(ManifestJson::effects(ManifestJson::encoded(shown)), shown);
// A key of the wrong type, or missing, refuses it.
for (const char *key : {"size", "red", "green", "blue", "opacity"}) {
QJsonObject wrong = encoded, bent = glow;
bent.insert(QLatin1String(key), "big");
wrong.insert("innerGlow", bent);
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
bent.remove(QLatin1String(key));
wrong.insert("innerGlow", bent);
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
}
}
void InnerGlowTests::innerGlowBackwardCompatibility()
{
// Effects written before the glow read without it.
const QJsonObject older{{"shadow", QJsonObject{{"angle", 90}, {"distance", 10}, {"blur", 15}, {"red", 0}, {"green", 0}, {"blue", 0}, {"opacity", 0.5}}}};
const LayerEffects decoded = ManifestJson::effects(older);
QVERIFY(decoded.shadow && !decoded.innerGlow && decoded.isValid());
}
void InnerGlowTests::layerEffectsIntegration()
{
LayerEffects effects;
QVERIFY(effects.isEmpty() && !effects.contains(LayerEffectKind::innerGlow));
effects.innerGlow = InnerGlowEffect{.size = 15};
QVERIFY(!effects.isEmpty() && effects.contains(LayerEffectKind::innerGlow) && effects.isEnabled(LayerEffectKind::innerGlow));
QVERIFY((effects.kinds() == std::vector{LayerEffectKind::innerGlow}));
effects.setEnabled(false, LayerEffectKind::innerGlow);
QVERIFY(!effects.isEnabled(LayerEffectKind::innerGlow) && !effects.visible().innerGlow);
effects.setColor(PaletteColor{1, 0.8, 0}, LayerEffectKind::innerGlow);
QVERIFY(effects.color(LayerEffectKind::innerGlow) == (PaletteColor{1, 0.8, 0}));
effects.remove(LayerEffectKind::innerGlow);
QVERIFY(!effects.contains(LayerEffectKind::innerGlow) && effects.isEmpty());
}
void InnerGlowTests::innerGlowRendersInsideSourceWithoutBoundsExpansion()
{
const LayerEffects effects = glowing(12, 1, 1, 0, 1);
// The margin stays the baseline two: nothing spills out.
QCOMPARE(LayerEffectsRenderer::margin(effects), 2.0);
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
QCOMPARE(rendered.image.pixelColor(0, 0).alpha(), 0);
QCOMPARE(at(rendered, -1, 20).alpha(), 0);
// Yellow near the edge, black in the deep centre.
const QColor edge = at(rendered, 2, 20), centre = at(rendered, 20, 20);
QVERIFY(edge.alphaF() > 0.9 && edge.redF() > 0.3 && edge.greenF() > 0.3 && edge.blueF() < 0.05);
QVERIFY(centre.redF() < 0.2 && centre.greenF() < 0.2);
// It fades inward: the edge brightest.
QVERIFY(at(rendered, 0, 20).redF() > at(rendered, 4, 20).redF() && at(rendered, 4, 20).redF() > at(rendered, 10, 20).redF());
}
void InnerGlowTests::innerGlowRendersAroundTextGlyphs()
{
// Swift's black O: a ring on clear.
QImage glyph(72, 72, QImage::Format_RGBA8888_Premultiplied);
glyph.fill(Qt::transparent);
QPainter painter(&glyph);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(Qt::black, 12));
painter.drawEllipse(QRectF(12, 12, 48, 48));
painter.end();
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(glyph, std::nullopt, glowing(8, 1, 0, 0, 0.9));
QVERIFY(rendered.image.width() >= glyph.width() && rendered.image.height() >= glyph.height());
QCOMPARE(rendered.image.pixelColor(0, 0).alpha(), 0);
// Swift's search: some pixel glows red; the hole is clear.
bool found = false;
for (int y = 0; y < glyph.height() && !found; ++y) {
for (int x = 0; x < glyph.width() && !found; ++x)
found = at(rendered, x, y).alphaF() > 0.5 && at(rendered, x, y).redF() > 0.3;
}
QVERIFY(found);
QCOMPARE(at(rendered, 36, 36).alpha(), 0);
}
void InnerGlowTests::itSitsOverTheOverlayAndUnderTheInnerShadow()
{
// Metal's order: overlay, inner glow, inner shadow.
LayerEffects effects = glowing(12, 0, 1, 0, 1);
effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 0, .blue = 1, .opacity = 1};
const LayerEffectsRenderer::Rendered covered = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
QVERIFY(at(covered, 0, 20).greenF() > 0.4 && at(covered, 20, 20).blueF() > 0.9);
effects.innerShadow = InnerShadowEffect{.angle = 0, .distance = 0, .blur = 0.01, .red = 1, .green = 0, .blue = 0, .opacity = 1};
const LayerEffectsRenderer::Rendered shadowed = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
QCOMPARE(at(shadowed, 0, 20), at(covered, 0, 20));
// A sideways shadow covers the glow on one edge.
effects.innerShadow->distance = 4;
const int x = effects.innerShadow->offset().width() > 0 ? 0 : 39;
const QColor edge = at(LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects), x, 20);
QVERIFY(std::abs(effects.innerShadow->offset().width()) > 3.9);
QVERIFY(edge.redF() > 0.9 && edge.greenF() < 0.1);
}
void InnerGlowTests::innerGlowPreservedInExport()
{
EditorSession session;
session.createDocument(100, 100);
session.insert(ImportedImage(square(30, Qt::black), QImage(), QStringLiteral("InnerGlowLayer")), QPointF(35, 35));
session.setEffects(glowing(8, 1, 0, 1, 0.9));
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
// The square spans 20 to 50: magenta at its edge.
const QColor edge = exported.pixelColor(21, 35), centre = exported.pixelColor(35, 35);
QVERIFY(edge.alphaF() > 0.8 && edge.redF() > 0.2 && edge.blueF() > 0.2 && edge.greenF() < 0.05);
QVERIFY(centre.redF() < 0.1 && centre.blueF() < 0.1);
QCOMPARE(exported.pixelColor(18, 35).alpha(), 0);
}
void InnerGlowTests::theSessionAddsCopiesAndCancelsIt()
{
EditorSession session;
session.createDocument(40, 40);
session.insert(ImportedImage(square(20, Qt::black), QImage(), QStringLiteral("One")));
const QUuid first = session.activeLayerID().value();
session.addEffect(LayerEffectKind::innerGlow);
QCOMPARE(session.history.undoName(), QString("Add Inner Glow"));
QVERIFY(session.activeEffects().innerGlow == InnerGlowEffect());
session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; });
session.finishEffectsEditing(false);
QVERIFY(!session.activeEffects().innerGlow);
session.addEffect(LayerEffectKind::innerGlow);
session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; });
session.finishEffectsEditing(true);
// Adding it again keeps the glow it has.
session.addEffect(LayerEffectKind::innerGlow);
QCOMPARE(session.activeEffects().innerGlow.value().size, 44.0);
session.finishEffectsEditing(true);
session.insert(ImportedImage(square(20, Qt::black), QImage(), QStringLiteral("Two")));
const QUuid second = session.activeLayerID().value();
QVERIFY(session.canCopyEffect(LayerEffectKind::innerGlow, first, second));
session.copyEffect(LayerEffectKind::innerGlow, first, second);
QCOMPARE(session.history.undoName(), QString("Copy Inner Glow"));
QCOMPARE(session.activeEffects().innerGlow.value().size, 44.0);
}
QTEST_MAIN(InnerGlowTests)
#include "InnerGlowTests.moc"
+4
View File
@@ -98,6 +98,8 @@ void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
const std::pair<AdjustmentKind, const char *> kinds[] = {{AdjustmentKind::hsv, "Hue/Saturation"}, {AdjustmentKind::levels, "Levels"},
{AdjustmentKind::curves, "Curves"}, {AdjustmentKind::exposure, "Exposure"},
{AdjustmentKind::gradientMap, "Gradient Map"}, {AdjustmentKind::grain, "Grain"},
{AdjustmentKind::addNoise, "Add Noise"}, {AdjustmentKind::gaussianBlur, "Gaussian Blur"},
{AdjustmentKind::motionBlur, "Motion Blur"},
{AdjustmentKind::invert, "Invert"}, {AdjustmentKind::blackWhite, "Black & White"},
{AdjustmentKind::colorBalance, "Color Balance"}};
QCOMPARE(allAdjustmentKinds.size(), std::size(kinds));
@@ -112,6 +114,8 @@ void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
QVERIFY(filterKind(AdjustmentKind::curves) == FilterKind::curves && filterKind(AdjustmentKind::exposure) == FilterKind::exposure);
QVERIFY(filterKind(AdjustmentKind::gradientMap) == FilterKind::gradientMap && filterKind(AdjustmentKind::grain) == FilterKind::grain);
QVERIFY(filterKind(AdjustmentKind::blackWhite) == FilterKind::blackWhite && filterKind(AdjustmentKind::colorBalance) == FilterKind::colorBalance);
QVERIFY(filterKind(AdjustmentKind::addNoise) == FilterKind::addNoise && filterKind(AdjustmentKind::gaussianBlur) == FilterKind::gaussianBlur);
QVERIFY(filterKind(AdjustmentKind::motionBlur) == FilterKind::motionBlur);
QVERIFY(!filterKind(AdjustmentKind::levels) && !filterKind(AdjustmentKind::hsv) && !filterKind(AdjustmentKind::invert));
// Every kind but Invert opens an editor.
for (const AdjustmentKind kind : allAdjustmentKinds)
+1 -1
View File
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
const QString path = folder.filePath("Appearance.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(8));
QCOMPARE(loaded.manifest.version, qint64(9));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
+21 -4
View File
@@ -88,7 +88,8 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
}
const auto on = [](const auto &effect) { return effect && effect->isEnabled() && effect->opacity > 0; };
const bool stroke = on(effects.stroke) && effects.stroke->size > 0;
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size());
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size()),
innerGlow(shader.shape.size());
if (stroke) {
const int reach = std::max(1, int(std::lround(effects.stroke->size)));
const std::vector<float> moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke->inside, true), reach, effects.stroke->inside, false);
@@ -101,6 +102,12 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
const bool glowing = on(effects.outerGlow) && effects.outerGlow->size > 0;
if (glowing)
glow = shader.softened(shader.shape, float(effects.outerGlow->size / 2));
const bool glowingInside = on(effects.innerGlow) && effects.innerGlow->size > 0;
if (glowingInside) {
const std::vector<float> softened = shader.softened(shader.shape, float(effects.innerGlow->size / 2));
for (size_t index = 0; index < innerGlow.size(); ++index)
innerGlow[index] = std::clamp(shader.shape[index] * (1.f - softened[index]), 0.f, 1.f);
}
if (on(effects.innerShadow)) {
const std::vector<float> moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow->offset().width()),
float(effects.innerShadow->offset().height())),
@@ -133,6 +140,8 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
alpha = sourceAlpha + alpha * (1.f - sourceAlpha);
if (on(effects.colorOverlay))
blend(effects.colorOverlay->color(), std::clamp(shader.shape[index] * float(effects.colorOverlay->opacity), 0.f, 1.f));
if (glowingInside)
blend(effects.innerGlow->color(), std::clamp(innerGlow[index] * float(effects.innerGlow->opacity), 0.f, 1.f));
if (on(effects.innerShadow))
blend(effects.innerShadow->color(), std::clamp(inner[index] * float(effects.innerShadow->opacity), 0.f, 1.f));
if (stroke && effects.stroke->inside)
@@ -194,7 +203,7 @@ private slots:
void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
{
QTest::addColumn<LayerEffects>("effects");
LayerEffects outside, inside, shadow, overlay, inner, glow, hair, all, wide;
LayerEffects outside, inside, shadow, overlay, inner, glow, hair, all, wide, innerGlow, innerHair;
outside.stroke = StrokeEffect{.size = 3, .red = 1, .green = 0.5, .blue = 0.25, .opacity = 0.8};
inside.stroke = StrokeEffect{.size = 2.5, .red = 0.1, .green = 0.2, .blue = 0.9, .opacity = 1, .inside = true};
shadow.shadow = ShadowEffect{.angle = 45, .distance = 3.5, .blur = 4, .red = 0.3, .green = 0.1, .blue = 0.6, .opacity = 0.75};
@@ -202,11 +211,14 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
inner.innerShadow = InnerShadowEffect{.angle = -60, .distance = 2, .blur = 3, .red = 0, .green = 0.4, .blue = 0.2, .opacity = 0.9};
glow.outerGlow = OuterGlowEffect{.size = 6, .red = 0.2, .green = 0.9, .blue = 0.4, .opacity = 0.8};
hair.outerGlow = OuterGlowEffect{.size = 0.01, .opacity = 1};
innerGlow.innerGlow = InnerGlowEffect{.size = 7, .red = 0.8, .green = 0.3, .blue = 0.1, .opacity = 0.7};
innerHair.innerGlow = InnerGlowEffect{.size = 0.01, .red = 0, .opacity = 1};
all = outside;
all.shadow = shadow.shadow;
all.colorOverlay = overlay.colorOverlay;
all.innerShadow = inner.innerShadow;
all.outerGlow = glow.outerGlow;
all.innerGlow = innerGlow.innerGlow;
all.shadow->blur = 0;
wide.stroke = StrokeEffect{.size = 9, .red = 1, .opacity = 1};
wide.shadow = ShadowEffect{.angle = 200, .distance = 0.5, .blur = 0.01, .opacity = 1};
@@ -217,6 +229,8 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
QTest::newRow("inner shadow") << inner;
QTest::newRow("outer glow") << glow;
QTest::newRow("a glow a hair wide") << hair;
QTest::newRow("inner glow") << innerGlow;
QTest::newRow("an inner glow a hair wide") << innerHair;
QTest::newRow("all, a sharp shadow") << all;
QTest::newRow("wide stroke, a barely blurred shadow") << wide;
}
@@ -244,14 +258,17 @@ void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels()
hidden.colorOverlay = ColorOverlayEffect{.enabled = false, .red = 1};
hidden.innerShadow = InnerShadowEffect{.enabled = false};
hidden.outerGlow = OuterGlowEffect{.enabled = false};
LayerEffects dim, flat;
hidden.innerGlow = InnerGlowEffect{.enabled = false};
LayerEffects dim, flat, dimInside, flatInside;
dim.outerGlow = OuterGlowEffect{.opacity = 0};
flat.outerGlow = OuterGlowEffect{.size = 0};
dimInside.innerGlow = InnerGlowEffect{.opacity = 0};
flatInside.innerGlow = InnerGlowEffect{.size = 0};
LayerEffects thin;
thin.stroke = StrokeEffect{.size = 0, .red = 1};
LayerEffects clear;
clear.shadow = ShadowEffect{.opacity = 0};
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear, dim, flat})
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear, dim, flat, dimInside, flatInside})
QCOMPARE(gap(LayerEffectsKernel::render(pixels, effects), pixels), 0);
// Stroke sizes round to whole pixels, one at least.
LayerEffects fraction, one;
+3 -1
View File
@@ -20,7 +20,7 @@ void addRows()
{
QTest::addColumn<LayerEffects>("effects");
const LayerEffects all = everyKind();
LayerEffects outside, inside, cast, overlay, inner, glow;
LayerEffects outside, inside, cast, overlay, inner, glow, innerGlow;
outside.stroke = all.stroke;
inside.stroke = StrokeEffect{.size = 10, .red = 1, .green = 0, .blue = 1, .opacity = 0.9, .inside = true};
cast.shadow = all.shadow;
@@ -33,6 +33,8 @@ void addRows()
QTest::newRow("overlay") << overlay;
QTest::newRow("inner shadow") << inner;
QTest::newRow("outer glow") << glow;
innerGlow.innerGlow = InnerGlowEffect{.size = 20, .red = 1, .green = 0.5, .blue = 0, .opacity = 0.9};
QTest::newRow("inner glow") << innerGlow;
QTest::newRow("every kind") << all;
}
+7 -3
View File
@@ -25,6 +25,7 @@ LayerEffects every()
effects.colorOverlay = ColorOverlayEffect{.enabled = true, .red = 0.9, .green = 0.25, .blue = 0.75, .opacity = 0.35};
effects.innerShadow = InnerShadowEffect{.angle = -30, .distance = 3, .blur = 2, .red = 0.05, .green = 0.15, .blue = 0.95, .opacity = 0.65};
effects.outerGlow = OuterGlowEffect{.size = 33, .red = 0.2, .green = 0.4, .blue = 0.6, .opacity = 0.55};
effects.innerGlow = InnerGlowEffect{.enabled = false, .size = 14, .red = 0.3, .green = 0.7, .blue = 0.1, .opacity = 0.45};
return effects;
}
@@ -81,7 +82,7 @@ void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
{
const std::pair<LayerEffectKind, const char *> kinds[] = {{LayerEffectKind::stroke, "Stroke"}, {LayerEffectKind::shadow, "Drop Shadow"},
{LayerEffectKind::colorOverlay, "Color Overlay"}, {LayerEffectKind::innerShadow, "Inner Shadow"},
{LayerEffectKind::outerGlow, "Outer Glow"}};
{LayerEffectKind::outerGlow, "Outer Glow"}, {LayerEffectKind::innerGlow, "Inner Glow"}};
QCOMPARE(allLayerEffectKinds.size(), std::size(kinds));
for (size_t index = 0; index < std::size(kinds); ++index) {
QVERIFY(allLayerEffectKinds[index] == kinds[index].first);
@@ -91,6 +92,7 @@ void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
QVERIFY((StrokeEffect() == StrokeEffect{std::nullopt, 4, 0, 0, 0, 1, false}));
QVERIFY((ShadowEffect() == ShadowEffect{std::nullopt, 90, 20, 20, 0, 0, 0, 0.5}));
QVERIFY((OuterGlowEffect() == OuterGlowEffect{std::nullopt, 20, 1, 1, 1, 0.75}));
QVERIFY((InnerGlowEffect() == InnerGlowEffect{std::nullopt, 10, 1, 1, 1, 0.75}));
QVERIFY((ColorOverlayEffect() == ColorOverlayEffect{std::nullopt, 0, 0, 0, 1}));
QVERIFY((InnerShadowEffect() == InnerShadowEffect{std::nullopt, 90, 10, 10, 0, 0, 0, 0.5}));
QCOMPARE(StrokeEffect::maxSize, 500.0);
@@ -168,7 +170,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
for (const LayerEffectKind kind : allLayerEffectKinds) {
LayerEffects full = every();
full.remove(kind);
QVERIFY(!full.contains(kind) && full.kinds().size() == 4);
QVERIFY(!full.contains(kind) && full.kinds().size() == 5);
full.setColor(PaletteColor{1, 0, 0}, kind);
QVERIFY(!full.color(kind));
}
@@ -193,7 +195,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
void LayerEffectsTests::theManifestWritesSwiftsKeys()
{
const QJsonObject encoded = ManifestJson::encoded(every());
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerShadow", "outerGlow", "shadow", "stroke"}));
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerGlow", "innerShadow", "outerGlow", "shadow", "stroke"}));
const QJsonObject stroke = encoded.value("stroke").toObject();
QCOMPARE(stroke.keys(), (QStringList{"blue", "enabled", "green", "inside", "opacity", "red", "size"}));
QVERIFY(stroke.value("enabled") == false && stroke.value("inside") == true && stroke.value("size") == 7);
@@ -203,6 +205,8 @@ void LayerEffectsTests::theManifestWritesSwiftsKeys()
QCOMPARE(encoded.value("innerShadow").toObject().value("angle"), QJsonValue(-30));
QCOMPARE(encoded.value("outerGlow").toObject().keys(), (QStringList{"blue", "green", "opacity", "red", "size"}));
QCOMPARE(encoded.value("outerGlow").toObject().value("size"), QJsonValue(33));
QCOMPARE(encoded.value("innerGlow").toObject().keys(), (QStringList{"blue", "enabled", "green", "opacity", "red", "size"}));
QVERIFY(encoded.value("innerGlow").toObject().value("size") == 14 && encoded.value("innerGlow").toObject().value("enabled") == false);
QVERIFY(ManifestJson::effects(encoded) == every());
// Absent effects stay out; null reads as absent.
LayerEffects one;
+2 -2
View File
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
ProjectStore::save(session->projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(8));
QCOMPARE(loaded.manifest.version, qint64(9));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QVERIFY(loaded.manifest.version == 8 && loaded.masks.count(folder) == 1);
QVERIFY(loaded.manifest.version == 9 && loaded.masks.count(folder) == 1);
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
+2 -2
View File
@@ -310,10 +310,10 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::addColumn<std::optional<ProjectError::Kind>>("refused");
const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge);
const std::optional<ProjectError::Kind> fine;
for (int version = 1; version <= 8; ++version)
for (int version = 1; version <= 9; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 9") << "" << 9 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 10") << "" << 10 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
+1 -1
View File
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
QCOMPARE(again.manifest.version, qint64(8));
QCOMPARE(again.manifest.version, qint64(9));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
+11 -9
View File
@@ -161,13 +161,15 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
const QString path = root.filePath("Invalid.comp"), metadata = path + "/manifest.json";
ProjectStore::save(snapshot, path);
const QByteArray good = contents(metadata);
// Each edit below replaces this text; it must be there.
QVERIFY(good.contains("\"version\": 9"));
const auto loading = [&](const QByteArray &manifest) {
overwrite(metadata, manifest);
return projectError([&] { ProjectStore::load(path); });
};
// A version out of range is named whatever else fails.
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
for (const qint64 strange : {qint64(42), qint64(9), qint64(0)}) {
for (const qint64 strange : {qint64(42), qint64(10), qint64(0)}) {
try {
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
ProjectStore::load(path);
@@ -182,12 +184,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
// Another format is invalid whatever version it claims.
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 0")), std::optional(ProjectError::Kind::version));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 0")), std::optional(ProjectError::Kind::version));
// Swift's Int holds 64 bits, each one: no double between.
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": " + QByteArray::number(wild)));
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": " + QByteArray::number(wild)));
ProjectStore::load(path);
QFAIL("a wild version opened");
} catch (const ProjectError &error) {
@@ -197,19 +199,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
}
}
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
// Declared: Qt rounds a token just past Int64's minimum.
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": -9223372036854775809"));
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": -9223372036854775809"));
ProjectStore::load(path);
QFAIL("a version below every integer opened");
} catch (const ProjectError &error) {
QCOMPARE(error.version, std::optional(least));
}
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
+2
View File
@@ -10,6 +10,7 @@
#include "UI/LevelsSheet.h"
#include "UI/NewCanvasSheet.h"
#include "UI/TransformInspector.h"
#include "UI/TrimSheet.h"
#include <QShortcut>
#include "UI/LassoControls.h"
#include "UI/NativeLayerList.h"
@@ -226,6 +227,7 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys()
const CanvasDocument canvas = session.document().value();
QCOMPARE(keys(CanvasSizeSheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {})), both);
QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both);
QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both);
QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, [](std::optional<QByteArray>) {})), both);
QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both);
QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"});
+5
View File
@@ -178,11 +178,15 @@ target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(FilterSessionTests)
compositor_test(FilterFailureTests)
compositor_test(LayerAdjustmentTests)
compositor_test(BlurNoiseAdjustmentTests)
# It checks the noise field against the kernel.
target_include_directories(BlurNoiseAdjustmentTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(AdjustmentLayerTests)
compositor_test(AdjustmentEditorTests)
compositor_test(AdjustmentPanelTests)
compositor_test(LayerEffectsTests)
compositor_test(OuterGlowTests)
compositor_test(InnerGlowTests)
compositor_test(LayerEffectsSessionTests)
compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
@@ -256,6 +260,7 @@ compositor_test(CropFailureTests)
compositor_test(CanvasSizeSheetTests)
compositor_test(ImageSizeSheetTests)
compositor_test(ProjectResizeTests)
compositor_test(ImageTrimTests)
compositor_test(JPEGExportSheetTests)
compositor_test(ProjectExportTests)
compositor_test(ProjectExportFailureTests)