Blur and noise adjustment layers, Image Trim, Inner Glow (12.8c)
This commit is contained in:
1 parent
7ae263de80
commit
31e2dd3864
60 files changed
+1673
-105
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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"});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user