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

+287
View File
@@ -0,0 +1,287 @@
#include "Document/BrushStroke.h"
#include "IO/CanvasResizer.h"
#include "IO/ImageExporter.h"
#include "IO/ImageResizer.h"
#include "SessionFixtures.h"
#include <QPainter>
#include <QTemporaryDir>
#include <QtTest>
// The session cases of Swift's LayerMaskTests.
namespace {
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)
{
const QList<int> exported = alphas(ImageExporter::render(session.projectSnapshot().value()).image);
// Export and the canvas's own composite must agree.
const CanvasDocument &document = session.document().value();
QImage canvas = BrushRaster::context(document.width, document.height, false);
{
QPainter painter(&canvas);
session.drawLiveComposite(document, painter);
}
if (alphas(canvas) != exported)
throw std::runtime_error("the live composite differs from the export");
return exported;
}
bool within(const QList<int> &values, const QList<int> &expected, int tolerance)
{
if (values.size() != expected.size())
return false;
for (qsizetype index = 0; index < values.size(); ++index) {
if (std::abs(values[index] - expected[index]) > tolerance)
return false;
}
return true;
}
bool isProjectError(const std::function<void()> &body)
{
try {
body();
} catch (const ProjectError &) {
return true;
}
return false;
}
}
class LayerMaskSessionTests : public QObject {
Q_OBJECT
private slots:
void addDisableDeleteUndoAndTargetSelection();
void coverageOpacityAndDisabledMasksRenderCorrectly();
void transformedMaskResizeAndCanvasChangesStayAligned();
void projectAndPNGPreserveCoverageAndDisabledState();
void masksRejectOlderSchemaAndUnsafePaths();
void folderMasksClipEveryLayerInsideAndMultiplyWithTheirOwnMasks();
void folderMasksSaveResizeAndNeedTheNewFormat();
void folderMaskCanBePaintedInvertedAndLoadedAsASelection();
};
void LayerMaskSessionTests::addDisableDeleteUndoAndTargetSelection()
{
const std::unique_ptr<EditorSession> session = redSession();
const ImageIdentity original = session->activeLayer().value().asset.value().identity();
const int count = session->history.undoCount();
session->addLayerMask(false);
QVERIFY(session->isMaskSelected() && session->activeLayer().value().mask.has_value());
QCOMPARE(session->activeLayer().value().mask.value().asset.size().width(), 1);
QCOMPARE(session->history.undoCount(), count + 1);
// An existing mask is not overwritten.
session->addLayerMask();
QCOMPARE(session->history.undoCount(), count + 1);
session->toggleLayerMask();
QVERIFY(!session->activeLayer().value().mask.value().isEnabled);
session->deleteLayerMask();
QVERIFY(!session->activeLayer().value().mask.has_value() && !session->isMaskSelected());
session->undo();
QVERIFY(!session->activeLayer().value().mask.value().isEnabled);
session->undo();
QVERIFY(session->activeLayer().value().mask.value().isEnabled);
session->undo();
QVERIFY(!session->activeLayer().value().mask.has_value());
session->redo();
const QUuid id = session->activeLayerID().value();
session->selectLayerTarget(id, true);
QVERIFY(session->isMaskSelected());
session->selectLayerTarget(id, false);
QVERIFY(!session->isMaskSelected() && session->activeLayer().value().asset.value().identity() == original);
session->addGroup();
session->addLayerMask();
QVERIFY(session->activeLayer().value().isGroup && session->activeLayer().value().mask.has_value());
}
void LayerMaskSessionTests::coverageOpacityAndDisabledMasksRenderCorrectly()
{
const std::unique_ptr<EditorSession> session = redSession();
const QUuid id = session->activeLayerID().value();
session->addLayerMask();
QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255}));
session->deleteLayerMask();
session->addLayerMask(false);
QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0}));
session->toggleLayerMask();
QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255}));
rewrite(*session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, coverage());
record(snapshot, id).maskEnabled = true;
});
QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255}));
session->setLayerOpacity(0.5);
QVERIFY2(within(rendered(*session), {128, 0, 64, 128}, 1), qPrintable(QString::number(rendered(*session)[2])));
}
void LayerMaskSessionTests::transformedMaskResizeAndCanvasChangesStayAligned()
{
const std::unique_ptr<EditorSession> session = redSession();
const QUuid id = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, coverage());
record(snapshot, id).transform.flipX = true;
});
const ProjectSnapshot input = session->projectSnapshot().value();
QCOMPARE(alphas(ImageExporter::render(input).image), (QList<int>{0, 255, 255, 128}));
const ProjectSnapshot resized = ImageResizer::resize(input, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
(QList<int>{0, 0, 255, 255, 0, 0, 255, 255, 255, 255, 128, 128, 255, 255, 128, 128}));
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 6, .height = 6});
QVERIFY(canvas.masks.at(id).identity() == resized.masks.at(id).identity());
session->applyDocumentSize(canvas, "Canvas Size");
QVERIFY(session->activeLayer().value().mask.has_value());
session->undo();
QCOMPARE(session->document().value().width, 2);
QCOMPARE(session->activeLayer().value().mask.value().asset.size().width(), 2);
// Turned, the same coverage falls on other pixels.
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 90; });
QList<int> turned = rendered(*session);
std::sort(turned.begin(), turned.end());
QCOMPARE(turned, (QList<int>{0, 128, 255, 255}));
}
void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
{
const std::unique_ptr<EditorSession> session = redSession();
const QUuid id = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, coverage()); });
QTemporaryDir folder;
const QString path = folder.filePath("Masks.comp");
for (const bool enabled : {true, false}) {
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(7));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
QCOMPARE(alphas(image), enabled ? (QList<int>{255, 0, 128, 255}) : (QList<int>{255, 255, 255, 255}));
}
// A missing mask file fails; it never reveals the image.
const QString maskFile = session->projectSnapshot().value().manifest.layers.front().maskFile.value();
QVERIFY(QFile::remove(path + "/images/" + maskFile));
QVERIFY(isProjectError([&] { ProjectStore::load(path); }));
}
void LayerMaskSessionTests::masksRejectOlderSchemaAndUnsafePaths()
{
const std::unique_ptr<EditorSession> session = redSession();
session->addLayerMask();
const ProjectSnapshot snapshot = session->projectSnapshot().value();
QTemporaryDir folder;
const QString path = folder.filePath("InvalidMask.comp");
ProjectSnapshot old = snapshot;
old.manifest.version = 3;
QVERIFY(isProjectError([&] { ProjectStore::save(old, path); }));
old.manifest.version = 4;
old.manifest.layers[0].maskFile = "../outside.png";
QVERIFY(isProjectError([&] { ProjectStore::save(old, path); }));
old.manifest.layers[0].maskFile = snapshot.manifest.layers[0].maskFile;
ProjectStore::save(old, path);
}
void LayerMaskSessionTests::folderMasksClipEveryLayerInsideAndMultiplyWithTheirOwnMasks()
{
const std::unique_ptr<EditorSession> session = redSession();
const QUuid red = session->activeLayerID().value();
session->groupSelectedLayers();
const QUuid folder = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, folder).transform.sampling = LayerSampling::nearest; });
QVERIFY(session->activeLayer().value().isGroup && session->canEditMask());
const int count = session->history.undoCount();
session->addLayerMask(false);
QVERIFY(session->isMaskSelected() && session->activeLayer().value().mask.has_value());
QCOMPARE(session->history.undoCount(), count + 1);
QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0}));
session->toggleLayerMask();
QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255}));
session->toggleLayerMask();
// Soft folder coverage, then times the layer's own soft mask.
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, coverage()); });
QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255}));
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, coverage()); });
QVERIFY2(within(rendered(*session), {255, 0, 64, 255}, 1), qPrintable(QString::number(rendered(*session)[2])));
// A folder around the folder masks them all as well.
rewrite(*session, [&](ProjectSnapshot &snapshot) {
record(snapshot, red).maskFile = std::nullopt;
snapshot.masks.erase(red);
});
session->selectLayer(folder);
session->groupSelectedLayers();
QVERIFY(session->activeLayerID() != std::optional(folder) && session->activeLayer().value().isGroup);
session->addLayerMask(false);
QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0}));
session->deleteLayerMask();
QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255}));
session->undo();
QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0}));
}
void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
{
const std::unique_ptr<EditorSession> session = redSession();
session->groupSelectedLayers();
const QUuid folder = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) {
record(snapshot, folder).transform.sampling = LayerSampling::nearest;
setMask(snapshot, folder, coverage());
});
QTemporaryDir directory;
const QString path = directory.filePath("FolderMask.comp");
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QVERIFY(loaded.manifest.version == 7 && 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),
(QList<int>{255, 255, 0, 0, 255, 255, 0, 0, 128, 128, 255, 255, 128, 128, 255, 255}));
// Earlier formats had no folder masks: such files are refused.
ProjectSnapshot old = snapshot;
old.manifest.version = 5;
QVERIFY(isProjectError([&] { ProjectStore::save(old, path); }));
}
void LayerMaskSessionTests::folderMaskCanBePaintedInvertedAndLoadedAsASelection()
{
EditorSession session;
session.createDocument(40, 20);
QImage red = BrushRaster::context(40, 20, false);
red.fill(Qt::red);
session.insert(ImportedImage(red, red, "Red"));
session.groupSelectedLayers();
const QUuid folder = session.activeLayerID().value();
session.addLayerMask();
QVERIFY(session.canPaint() && session.canInvert() && session.canCopyPixels());
// Black on the folder's mask hides the layer there alone.
session.selectTool(NavigationTool::brush);
session.setBrushSettings(BrushSettings{.diameter = 8, .hardness = 1, .red = 0, .green = 0, .blue = 0});
session.setMaskPaintWhite(false);
session.beginBrush(QPointF(10, 10));
session.continueBrush(QPointF(11, 10));
session.finishBrush();
QList<int> values = alphas(ImageExporter::render(session.projectSnapshot().value()).image);
QVERIFY(values[10 * 40 + 10] == 0 && values[10 * 40 + 30] == 255);
QVERIFY(session.activeLayerID() == folder && session.isMaskSelected());
// Invert swaps what the folder hides.
bool done = false;
session.invertPixels([&] { done = true; });
QTRY_VERIFY(done);
values = alphas(ImageExporter::render(session.projectSnapshot().value()).image);
QVERIFY(values[10 * 40 + 10] == 255 && values[10 * 40 + 30] == 0);
// Ctrl-click on the folder's mask thumbnail selects its black.
session.loadMaskSelection(folder);
QVERIFY(session.selection().value().path.boundingRect().width() > 30);
}
QTEST_GUILESS_MAIN(LayerMaskSessionTests)
#include "LayerMaskSessionTests.moc"