OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
#include "Document/Curves.h"
#include "Document/EditorSession.h"
#include "Document/ImageAdjustments.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore+Json.h"
#include <QJsonDocument>
#include <QtTest>
#include <set>
// Swift's ImageAdjustmentTests.
namespace {
// A width by height image of one straight sRGB colour.
QImage image(int width, int height, double red, double green, double blue, double alpha = 1)
{
QImage result(width, height, QImage::Format_RGBA8888_Premultiplied);
result.fill(QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)));
return result;
}
QImage gray(int width = 4, int height = 4, double alpha = 1)
{
return image(width, height, 128.0 / 255, 128.0 / 255, 128.0 / 255, alpha);
}
// Straight RGBA bytes of every pixel, top row first.
std::vector<std::array<int, 4>> pixels(const QImage &image)
{
const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
std::vector<std::array<int, 4>> result;
for (int y = 0; y < drawn.height(); ++y) {
const uchar *row = drawn.constScanLine(y);
for (int x = 0; x < drawn.width(); ++x) {
const int a = row[x * 4 + 3];
std::array<int, 4> pixel{0, 0, 0, a};
for (int c = 0; c < 3; ++c)
pixel[size_t(c)] = a == 0 ? 0 : std::min(255, (row[x * 4 + c] * 255 + a / 2) / a);
result.push_back(pixel);
}
}
return result;
}
bool committed(EditorSession &session)
{
bool done = false;
session.commitFilter([&done] { done = true; });
return QTest::qWaitFor([&done] { return done; }, 20000);
}
}
class ImageAdjustmentTests : public QObject {
Q_OBJECT
private slots:
void exposureWorksInLinearLightWithOffsetAndGamma();
void gradientMapColorsByBrightnessAndReverses();
void grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone();
void imageMenuExposureChangesTheLayerInOneStep();
void gradientMapColorsUseTheAppColorPicker();
void settingsSaveAndOlderAdjustmentsStillOpen();
void newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel();
};
void ImageAdjustmentTests::exposureWorksInLinearLightWithOffsetAndGamma()
{
const QImage input = gray();
QVERIFY(pixels(ExposureSettings().apply(input)) == pixels(input));
const std::array<int, 4> brighter = pixels(ExposureSettings{1, 0, 1}.apply(input))[0];
QVERIFY2(std::abs(brighter[0] - 176) <= 2, "+1 stop doubles linear light");
QCOMPARE(brighter[0], brighter[2]);
const std::array<int, 4> lifted = pixels(ExposureSettings{0, 0, 2}.apply(input))[0];
QVERIFY2(std::abs(lifted[0] - 181) <= 2, "gamma 2 takes the square root of linear light");
const std::array<int, 4> offset = pixels(ExposureSettings{0, 0.1, 1}.apply(image(4, 4, 0, 0, 0)))[0];
QVERIFY2(std::abs(offset[0] - 89) <= 2, "offset adds linear light");
const QImage translucent = gray(4, 4, 0.5);
QCOMPARE(pixels(ExposureSettings{1, 0, 1}.apply(translucent))[0][3], pixels(translucent)[0][3]);
}
void ImageAdjustmentTests::gradientMapColorsByBrightnessAndReverses()
{
GradientMapSettings settings{{1, 0, 0}, {0, 0, 1}, false};
QVERIFY((pixels(settings.apply(image(4, 4, 0, 0, 0)))[0] == std::array<int, 4>{255, 0, 0, 255}));
QVERIFY((pixels(settings.apply(image(4, 4, 1, 1, 1)))[0] == std::array<int, 4>{0, 0, 255, 255}));
const std::array<int, 4> middle = pixels(settings.apply(gray()))[0];
QVERIFY(std::abs(middle[0] - 127) <= 2);
QVERIFY(std::abs(middle[2] - 128) <= 2);
QCOMPARE(middle[1], 0);
const std::array<int, 4> translucent = pixels(settings.apply(image(4, 4, 1, 1, 1, 0.5)))[0];
QVERIFY(translucent[2] >= 250);
QVERIFY(translucent[0] <= 5);
QVERIFY2(std::abs(translucent[3] - 128) <= 1, "alpha kept");
settings.reversed = true;
QVERIFY((pixels(settings.apply(image(4, 4, 0, 0, 0)))[0] == std::array<int, 4>{0, 0, 255, 255}));
}
void ImageAdjustmentTests::grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone()
{
const GrainSettings settings{60, 2, 40, 7};
const std::vector<std::array<int, 4>> whole = pixels(settings.apply(gray(40, 40)));
std::set<int> reds;
for (const std::array<int, 4> &pixel : whole)
reds.insert(pixel[0]);
QVERIFY2(reds.size() > 5, "grain varies the brightness");
QVERIFY2(std::all_of(whole.begin(), whole.end(), [](const std::array<int, 4> &pixel) { return pixel[0] == pixel[1] && pixel[1] == pixel[2]; }),
"the same change on every channel");
// A piece at its place gets the whole's grain there.
const std::vector<std::array<int, 4>> part = pixels(settings.apply(gray(20, 20), QPointF(10, 10)));
std::vector<std::array<int, 4>> crop;
for (int y = 0; y < 20; ++y) {
for (int x = 0; x < 20; ++x)
crop.push_back(whole[size_t((y + 10) * 40 + x + 10)]);
}
QVERIFY2(part == crop, "grain must not shift when only part of the canvas redraws");
GrainSettings reseeded = settings;
reseeded.seed = 8;
QVERIFY2(pixels(reseeded.apply(gray(40, 40))) != whole, "another seed, another pattern");
QVERIFY2(pixels(GrainSettings{0, 1.5, 50, 0}.apply(gray())) == pixels(gray()), "no amount, no change");
const std::vector<std::array<int, 4>> cleared = pixels(settings.apply(gray(4, 4, 0)));
QVERIFY2(std::all_of(cleared.begin(), cleared.end(), [](const std::array<int, 4> &pixel) { return pixel[3] == 0; }), "clear pixels stay clear");
}
void ImageAdjustmentTests::imageMenuExposureChangesTheLayerInOneStep()
{
EditorSession session;
session.createDocument(8, 8);
const QImage base = gray(8, 8);
session.insert(ImportedImage(base, base, QStringLiteral("Gray")));
session.beginFilter(FilterKind::exposure);
FilterSettings settings = session.filterEdit().value().settings;
settings.exposure.exposure = 1;
session.updateFilter(settings, true);
const int count = session.history.undoCount();
QVERIFY(committed(session));
QVERIFY(session.history.undoCount() == count + 1 && session.history.undoName() == QStringLiteral("Exposure"));
const std::array<int, 4> result = pixels(session.activeLayer().value().asset.value().image())[0];
QVERIFY2(std::abs(result[0] - 176) <= 2, QByteArray::number(result[0]));
QVERIFY(isImageAdjustment(FilterKind::exposure) && !isImageAdjustment(FilterKind::gaussianBlur));
}
void ImageAdjustmentTests::gradientMapColorsUseTheAppColorPicker()
{
EditorSession session;
session.createDocument(8, 8);
const QImage base = gray(8, 8);
session.insert(ImportedImage(base, base, QStringLiteral("Gray")));
const PaletteColor foreground = session.foregroundColor(), background = session.backgroundColor();
session.beginFilter(FilterKind::gradientMap);
const auto map = [&session] { return session.filterEdit().value().settings.gradientMap; };
const AdjustmentColor start = map().highlights;
session.openGradientMapColorPicker(true);
const ColorPickerState picker = session.colorPicker().value();
QVERIFY((picker.target == ColorPickerTarget{ColorPickerTarget::Kind::gradientMap, false, std::nullopt, true}));
QVERIFY(AdjustmentColor(picker.original) == start);
PickerHSB hsb = picker.hsb;
hsb.setRGB(PaletteColor{1, 0, 0});
session.setColorPickerHSB(hsb);
session.previewGradientMapColor();
QVERIFY2(map().highlights == AdjustmentColor(1, 0, 0), "the gradient follows the working color");
session.closeColorPicker(false);
QVERIFY2(map().highlights == start, "Cancel restores it");
session.openGradientMapColorPicker(false);
hsb = session.colorPicker().value().hsb;
hsb.setRGB(PaletteColor{0, 0, 1});
session.setColorPickerHSB(hsb);
session.closeColorPicker(true);
QVERIFY(map().shadows == AdjustmentColor(0, 0, 1));
QVERIFY(session.foregroundColor() == foreground);
QVERIFY2(session.backgroundColor() == background, "the palette is untouched");
session.openGradientMapColorPicker(true);
session.cancelFilter();
QVERIFY2(!session.colorPicker() && !session.filterEdit(), "closing the panel closes its picker");
}
void ImageAdjustmentTests::settingsSaveAndOlderAdjustmentsStillOpen()
{
const LayerAdjustment levels{AdjustmentKind::levels};
const QJsonObject data = ManifestJson::encoded(levels);
const QString json = QString::fromUtf8(QJsonDocument(data).toJson());
QVERIFY2(!json.contains("exposureSettings"), "an existing kind saves exactly as before");
QVERIFY(!json.contains("gradientMapSettings"));
QVERIFY(!json.contains("grainSettings"));
QVERIFY(ManifestJson::adjustment(data) == levels);
LayerAdjustment grain{AdjustmentKind::grain};
grain.setGrain(GrainSettings{.amount = 40, .size = 3, .roughness = 10, .seed = 9});
const LayerAdjustment decoded = ManifestJson::adjustment(ManifestJson::encoded(grain));
QVERIFY(decoded == grain);
QVERIFY(decoded.isValid());
LayerAdjustment broken{AdjustmentKind::exposure};
ExposureSettings exposure = broken.exposure();
exposure.gamma = 0;
broken.setExposure(exposure);
QVERIFY(!broken.isValid());
}
void ImageAdjustmentTests::newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel()
{
EditorSession session;
session.createDocument(20, 20);
const QImage base = gray(20, 20);
session.insert(ImportedImage(base, base, QStringLiteral("Gray")));
session.setPaletteColor(PaletteColor{1, 0, 0}, false);
session.setPaletteColor(PaletteColor{0, 0, 1}, true);
session.addAdjustment(AdjustmentKind::gradientMap);
const QUuid id = session.activeLayerID().value();
const LayerAdjustment adjustment = session.activeLayer().value().adjustment.value();
QVERIFY(adjustment.kind == AdjustmentKind::gradientMap);
QVERIFY(adjustment.gradientMap().shadows == AdjustmentColor(1, 0, 0));
QVERIFY(adjustment.gradientMap().highlights == AdjustmentColor(0, 0, 1));
QCOMPARE(session.adjustmentEditingID(), std::optional(id));
bool done = false;
session.beginAdjustmentEditing(id, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY2(session.filterEdit().value().kind == FilterKind::gradientMap, "edited in the floating filter panel, like Curves");
session.finishAdjustmentEditing(false);
const std::array<int, 4> middle = pixels(ImageExporter::render(session.projectSnapshot().value()).image)[210];
const QString shown = QStringLiteral("%1 %2 %3").arg(middle[0]).arg(middle[1]).arg(middle[2]);
QVERIFY2(middle[0] > 100, qPrintable("gray maps between red and blue: " + shown));
QVERIFY2(middle[2] > 100, qPrintable(shown));
QVERIFY2(middle[1] < 20, qPrintable(shown));
session.addAdjustment(AdjustmentKind::grain);
const quint32 first = session.activeLayer().value().adjustment.value().grain().seed;
// This test never opened its panel: no edit to finish.
session.setAdjustmentEditingID(std::nullopt);
session.addAdjustment(AdjustmentKind::grain);
const std::vector<ImageLayer> &layers = session.document().value().layers;
QCOMPARE(std::count_if(layers.begin(), layers.end(), [](const ImageLayer &layer) { return layer.adjustment && layer.adjustment->kind == AdjustmentKind::grain; }), 2);
QVERIFY2(session.activeLayer().value().adjustment.value().grain().seed != first, "each Grain layer gets its own pattern");
QVERIFY(std::find(allAdjustmentKinds.begin(), allAdjustmentKinds.end(), AdjustmentKind::exposure) != allAdjustmentKinds.end()
&& filterKind(AdjustmentKind::exposure) == FilterKind::exposure);
}
QTEST_GUILESS_MAIN(ImageAdjustmentTests)
#include "ImageAdjustmentTests.moc"