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

+218
View File
@@ -0,0 +1,218 @@
#include "Document/LiveLayerMask.h"
#include "IO/ImageExporter.h"
#include "SessionFixtures.h"
#include <QTemporaryDir>
#include <QtTest>
// The session cases of Swift's LiveMaskTests.
namespace {
// Two by two, red by `color`, with these alphas, premultiplied.
ImportedImage asset(const QList<int> &alpha, int color = 255)
{
QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied);
for (int index = 0; index < 4; ++index) {
uchar *pixel = image.scanLine(index / 2) + (index % 2) * 4;
pixel[0] = uchar(color * alpha[index] / 255);
pixel[1] = 0;
pixel[2] = 0;
pixel[3] = uchar(alpha[index]);
}
return ImportedImage(image, image, "Fixture");
}
QList<int> alphas(const QImage &image)
{
QList<int> result;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x)
result << image.pixelColor(x, y).alpha();
}
return result;
}
QList<int> rendered(const EditorSession &session)
{
return alphas(ImageExporter::render(session.projectSnapshot().value()).image);
}
std::vector<QUuid> ids(const EditorSession &session)
{
std::vector<QUuid> result;
for (const ImageLayer &layer : session.document().value().layers)
result.push_back(layer.id);
return result;
}
std::optional<QUuid> base(const EditorSession &session, size_t index)
{
return session.document().value().layers.at(index).maskSourceID;
}
void sampleNearest(EditorSession &session)
{
rewrite(session, [](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &layer : snapshot.manifest.layers)
layer.transform.sampling = LayerSampling::nearest;
});
}
// An opaque layer clipped to a hidden, mixed-alpha one.
std::unique_ptr<EditorSession> fixture()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(2, 2);
session->insert(asset({255, 255, 255, 255}));
session->insert(asset({255, 0, 128, 255}, 0));
const std::vector<QUuid> layers = ids(*session);
rewrite(*session, [&](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &layer : snapshot.manifest.layers)
layer.transform.sampling = LayerSampling::nearest;
record(snapshot, layers[1]).isVisible = false;
});
if (!session->linkMask(layers[1], layers[0]))
throw std::runtime_error("the fixture cannot link its mask");
return session;
}
}
class LiveMaskSessionTests : public QObject {
Q_OBJECT
private slots:
void clippingColorPreservesSoftBaseAlphaWithoutBlackFringe();
void optionClickCreatesSharedStackAndDragOutReleases();
void hiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply();
void cyclesUndoPersistenceBakeAndDelete();
void movingSourceChangesCoverageAndChainsMultiply();
};
void LiveMaskSessionTests::clippingColorPreservesSoftBaseAlphaWithoutBlackFringe()
{
EditorSession session;
session.createDocument(2, 2);
session.insert(asset({255, 128, 32, 0}, 0));
session.insert(asset({255, 255, 255, 255}));
const QUuid id = session.activeLayerID().value();
session.toggleClippingMask(id);
sampleNearest(session);
const QImage result = ImageExporter::render(session.projectSnapshot().value()).image;
QCOMPARE(alphas(result), (QList<int>{255, 128, 32, 0}));
// Red times alpha, no black fringe: red equals alpha.
const QImage flat = result.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
for (int index = 0; index < 4; ++index) {
const uchar *pixel = flat.constScanLine(index / 2) + (index % 2) * 4;
QCOMPARE(int(pixel[0]), int(pixel[3]));
QVERIFY(pixel[1] == 0 && pixel[2] == 0);
}
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).opacity = 0.5; });
QCOMPARE(rendered(session), (QList<int>{255, 128, 32, 0}));
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).opacity = 1; });
QImage white(2, 2, QImage::Format_RGBA8888_Premultiplied);
white.fill(Qt::white);
session.insert(ImportedImage(white, white, "White"));
const QUuid background = session.activeLayerID().value();
QVERIFY(session.placeLayer(background, std::nullopt, std::nullopt, true));
const QImage flattened = ImageExporter::render(session.projectSnapshot().value()).image;
QCOMPARE(alphas(flattened), (QList<int>{255, 255, 255, 255}));
for (int index = 0; index < 4; ++index)
QCOMPARE(flattened.pixelColor(index % 2, index / 2).red(), 255);
}
void LiveMaskSessionTests::optionClickCreatesSharedStackAndDragOutReleases()
{
EditorSession session;
session.createDocument(2, 2);
for (int index = 0; index < 3; ++index)
session.insert(asset({255, 255, 255, 255}));
const std::vector<QUuid> layers = ids(session);
QVERIFY(!session.canToggleClippingMask(layers[0]) && session.canToggleClippingMask(layers[1]));
session.toggleClippingMask(layers[0]);
QCOMPARE(base(session, 0), std::nullopt);
session.toggleClippingMask(layers[1]);
session.toggleClippingMask(layers[2]);
QCOMPARE(base(session, 1), std::optional(layers[0]));
QCOMPARE(base(session, 2), std::optional(layers[0]));
QVERIFY(session.canToggleClippingMask(layers[2]));
session.toggleClippingMask(layers[2]);
QCOMPARE(base(session, 2), std::nullopt);
QCOMPARE(base(session, 1), std::optional(layers[0]));
session.toggleClippingMask(layers[2]);
session.toggleClippingMask(layers[1]);
QVERIFY(!base(session, 1).has_value() && !base(session, 2).has_value());
session.undo();
QCOMPARE(base(session, 2), std::optional(layers[0]));
QVERIFY(session.placeLayer(layers[2], std::nullopt, std::nullopt, true));
QVERIFY(session.document().value().layers.front().id == layers[2] && !base(session, 0).has_value());
QCOMPARE(session.document().value().layers.back().maskSourceID, std::optional(layers[0]));
session.undo();
QVERIFY(session.document().value().layers.back().id == layers[2]);
QCOMPARE(session.document().value().layers.back().maskSourceID, std::optional(layers[0]));
}
void LiveMaskSessionTests::hiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply()
{
const std::unique_ptr<EditorSession> session = fixture();
const std::vector<QUuid> layers = ids(*session);
QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255}));
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).opacity = 0.5; });
const QList<int> half = rendered(*session);
QVERIFY2(std::abs(half[0] - 128) <= 1 && half[1] == 0 && std::abs(half[2] - 64) <= 1, qPrintable(QString::number(half[0])));
QImage gray(2, 2, QImage::Format_Grayscale8);
gray.fill(128);
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, layers[0], LayerMask::assetFrom(gray)); });
QVERIFY(rendered(*session)[0] < half[0]);
}
void LiveMaskSessionTests::cyclesUndoPersistenceBakeAndDelete()
{
const std::unique_ptr<EditorSession> fixed = fixture();
EditorSession &session = *fixed;
const QUuid target = ids(session)[0], source = ids(session)[1];
QCOMPARE(session.history.undoName(), QString("Create Clipping Mask"));
QVERIFY(!session.linkMask(target, source));
QVERIFY(!session.linkMask(target, target));
session.undo();
QCOMPARE(base(session, 0), std::nullopt);
session.redo();
QCOMPARE(base(session, 0), std::optional(source));
const ProjectSnapshot snapshot = session.projectSnapshot().value();
QTemporaryDir folder;
const QString path = folder.filePath("LiveMask.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.layers[0].maskSourceID, std::optional(source));
const QList<int> before = alphas(ImageExporter::render(loaded).image);
const ImportedImage baked = LiveMaskBaker::bake(loaded, target).value();
session.finishDeletingLayer(source, {{target, baked}});
QCOMPARE(rendered(session), before);
QCOMPARE(base(session, 0), std::nullopt);
session.undo();
QCOMPARE(int(session.document().value().layers.size()), 2);
QCOMPARE(base(session, 0), std::optional(source));
std::vector<ProjectLayerRecord> bad = snapshot.manifest.layers;
bad[1].maskSourceID = target;
bool refused = false;
try {
LiveMaskGraph::validate(bad);
} catch (const ProjectError &) {
refused = true;
}
QVERIFY(refused);
}
void LiveMaskSessionTests::movingSourceChangesCoverageAndChainsMultiply()
{
const std::unique_ptr<EditorSession> session = fixture();
const std::vector<QUuid> layers = ids(*session);
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).transform.origin.rx() += 1; });
QCOMPARE(rendered(*session), (QList<int>{0, 255, 0, 128}));
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).transform.origin.rx() -= 1; });
session->selectLayer(layers[1]);
session->insert(asset({0, 255, 255, 255}));
const QUuid third = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, third).isVisible = false; });
QVERIFY(session->linkMask(third, layers[1]));
QCOMPARE(rendered(*session), (QList<int>{0, 0, 128, 255}));
}
QTEST_GUILESS_MAIN(LiveMaskSessionTests)
#include "LiveMaskSessionTests.moc"