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

+239
View File
@@ -0,0 +1,239 @@
#include "Document/Distort.h"
#include "IO/ImageExporter.h"
#include "SessionFixtures.h"
#include <QtTest>
// Masks under a distortion: along with the layer, or alone.
namespace {
// The next thumbnail fails, as an allocation might.
bool failNextScale = false;
}
// Takes libQt6Gui's place, as ProjectReplaceTests takes libc's.
QImage QImage::scaled(const QSize &size, Qt::AspectRatioMode aspectMode, Qt::TransformationMode mode) const
{
if (failNextScale) {
failNextScale = false;
return QImage();
}
if (size == this->size() && aspectMode == Qt::IgnoreAspectRatio && mode == Qt::SmoothTransformation)
return *this;
// Every scale these tests make is the image's own size.
throw std::logic_error("DistortTests scale nothing");
}
namespace {
const Corners shape = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(10, 30)};
QImage red(int width, int height)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::red);
return image;
}
QUuid insertAt(EditorSession &session, const QImage &image, QPointF origin)
{
session.insert(ImportedImage(image, image, "Image"));
const QUuid id = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = origin; });
return id;
}
int alphaAt(const EditorSession &session, int x, int y)
{
return ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(x, y).alpha();
}
}
class DistortMaskTests : public QObject {
Q_OBJECT
private slots:
void masksFollowADistortionOrStayPut();
void anUnlinkedMaskDistortsOnItsOwn();
};
void DistortMaskTests::masksFollowADistortionOrStayPut()
{
EditorSession session;
session.createDocument(100, 60);
const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10));
QImage half(20, 20, QImage::Format_Grayscale8);
half.fill(0);
for (int y = 0; y < 20; ++y)
std::fill_n(half.scanLine(y), 10, uchar(255));
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(half)); });
session.selectTool(NavigationTool::move);
session.beginTransform(false);
session.beginDistort();
session.previewCorners(shape);
// The preview carries the linked mask, cached while unchanged.
const std::optional<DistortPreview> preview = session.distortPreview(layerWith(session, id));
QCOMPARE(preview.value().transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}}));
QCOMPARE(preview.value().image.size(), QSize(50, 20));
QCOMPARE(preview.value().mask.value().size(), QSize(50, 20));
QCOMPARE(int(preview.value().mask.value().constScanLine(2)[5]), 255);
QCOMPARE(int(preview.value().mask.value().constScanLine(2)[45]), 0);
QCOMPARE(session.distortPreview(layerWith(session, id)).value().image.cacheKey(), preview.value().image.cacheKey());
QVERIFY(!session.displayedMaskPlacement(layerWith(session, id)).has_value());
QVERIFY(!session.maskDistortPreview(layerWith(session, id)).has_value());
session.previewCorners({shape[0], QPointF(70, 10), shape[2], shape[3]});
QVERIFY(session.distortPreview(layerWith(session, id)).value().image.cacheKey() != preview.value().image.cacheKey());
session.previewCorners(shape);
session.commitTransform();
const ImageLayer distorted = layerWith(session, id);
QCOMPARE(distorted.mask.value().asset.size(), QSize(50, 20));
QVERIFY(!distorted.mask.value().placement.has_value());
QCOMPARE(alphaAt(session, 15, 15), 255);
QCOMPARE(alphaAt(session, 55, 12), 0);
session.undo();
// An unlinked mask keeps its place on the document.
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskLinked = false; });
session.beginTransform(false);
session.beginDistort();
QCOMPARE(session.displayedMaskPlacement(layerWith(session, id)), std::optional(layerWith(session, id).transform));
session.previewCorners(shape);
QCOMPARE(session.distortPreview(layerWith(session, id)).value().mask.value().size(), QSize(50, 20));
session.commitTransform();
QCOMPARE(layerWith(session, id).mask.value().placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {20, 20}}));
QCOMPARE(layerWith(session, id).mask.value().asset.size(), QSize(20, 20));
session.undo();
// A linked mask apart under a fold keeps its place.
const LayerTransform apart{.origin = {12, 12}, .size = {10, 10}};
rewrite(session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).maskLinked = true;
record(snapshot, id).maskPlacement = apart;
});
session.beginTransform();
session.previewTransform({.origin = {20, 10}, .size = {20, 20}});
session.beginDistort();
session.previewCorners({QPointF(20, 10), QPointF(40, 10), QPointF(22, 12), QPointF(20, 30)});
const DistortPreview folded = session.distortPreview(layerWith(session, id)).value();
const LayerMask owned = layerWith(session, id).mask.value();
QCOMPARE(folded.mask.value(), owned.clipImage(apart, folded.transform, folded.image.width(), folded.image.height(), 2048).value());
session.cancelTransform();
// A convex shape carries a placed linked mask along, resampled.
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskPlacement = apart; });
session.beginTransform();
session.beginDistort();
session.previewCorners(shape);
const ImageLayer before = layerWith(session, id);
const Corners carried = DistortWarp::carried(apart, before.transform, shape);
QVERIFY(DistortWarp::isConvex(carried));
const DistortWarp::Warped expected = DistortWarp::warpMask(before.mask.value().asset.image(), apart, carried, LayerMask::background(before.mask.value().asset.thumbnail));
const DistortPreview carriedPreview = session.distortPreview(before).value();
QCOMPARE(carriedPreview.mask.value(), LayerMask(ImportedImage(expected.image, before.mask.value().asset.thumbnail, "m")).clipImage(expected.transform, carriedPreview.transform, 50, 20, 2048).value());
QVERIFY(carriedPreview.mask.value() != before.mask.value().clipImage(apart, carriedPreview.transform, 50, 20, 2048).value());
session.commitTransform();
const LayerMask moved = layerWith(session, id).mask.value();
QCOMPARE(moved.placement, std::optional(expected.transform));
QCOMPARE(moved.asset.image(), expected.image);
QVERIFY(moved.isLinked);
QVERIFY(expected.transform.origin != apart.origin);
session.undo();
// A placement too wide to warp: the layer still previews.
const LayerTransform wide{.origin = {12, 12}, .size = {40'000, 10}};
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskPlacement = wide; });
session.beginTransform();
session.beginDistort();
session.previewCorners(shape);
const DistortPreview kept = session.distortPreview(layerWith(session, id)).value();
QCOMPARE(kept.image.size(), QSize(50, 20));
QCOMPARE(kept.mask.value(), layerWith(session, id).mask.value().clipImage(wide, kept.transform, 50, 20, 2048).value());
}
void DistortMaskTests::anUnlinkedMaskDistortsOnItsOwn()
{
EditorSession session;
session.createDocument(100, 60);
const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10));
// A white block in a black border: black background.
QImage block(20, 20, QImage::Format_Grayscale8);
block.fill(0);
for (int y = 2; y < 18; ++y)
std::fill_n(block.scanLine(y) + 2, 8, uchar(255));
rewrite(session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, LayerMask::assetFrom(block));
record(snapshot, id).maskLinked = false;
});
session.selectLayerTarget(id, true);
QVERIFY(session.transformTargetsMask());
session.beginTransform();
session.beginDistort();
QVERIFY(session.transformEdit().value().mask && session.transformEdit().value().corners.has_value());
// The mask shrunk into the layer's top left quarter.
const Corners quarter = {QPointF(10, 10), QPointF(20, 10), QPointF(20, 20), QPointF(10, 20)};
session.previewCorners(quarter);
// The preview is the warped mask in the layer's grid.
const std::optional<QImage> preview = session.maskDistortPreview(layerWith(session, id));
QCOMPARE(preview.value().size(), QSize(20, 20));
QCOMPARE(int(preview.value().constScanLine(3)[3]), 255);
QCOMPARE(int(preview.value().constScanLine(15)[15]), 0);
QCOMPARE(session.maskDistortPreview(layerWith(session, id)).value().cacheKey(), preview.value().cacheKey());
QVERIFY(!session.distortPreview(layerWith(session, id)).has_value());
session.previewCorners({QPointF(10, 10), QPointF(22, 10), QPointF(20, 20), QPointF(10, 20)});
QVERIFY(session.maskDistortPreview(layerWith(session, id)).value().cacheKey() != preview.value().cacheKey());
session.previewCorners(quarter);
const int count = session.history.undoCount();
session.commitTransform();
QCOMPARE(session.history.undoName(), QString("Distort Layer Mask"));
QCOMPARE(session.history.undoCount(), count + 1);
const LayerMask mask = layerWith(session, id).mask.value();
QCOMPARE(mask.asset.size(), QSize(10, 10));
QCOMPARE(mask.placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {10, 10}}));
QVERIFY(!mask.isLinked);
QCOMPARE(layerWith(session, id).transform.size, QSizeF(20, 20));
// Past the mask, its black background hides the layer.
QCOMPARE(alphaAt(session, 13, 13), 255);
QCOMPARE(alphaAt(session, 25, 25), 0);
// A mask apart keeps its place when its corners stay.
session.beginTransform();
session.beginDistort();
session.commitTransform();
QCOMPARE(session.history.undoName(), QString("Distort Layer Mask"));
QCOMPARE(layerWith(session, id).mask.value().placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {10, 10}}));
// A warp that fails opens no transaction: undo stays usable.
const int steps = session.history.undoCount();
session.beginTransform();
session.beginDistort();
session.previewCorners({QPointF(10, 10), QPointF(40'010, 10), QPointF(40'010, 20), QPointF(10, 20)});
session.commitTransform();
QVERIFY(session.brushError().has_value());
QVERIFY(!session.transformEdit().has_value() && session.canUndo());
QCOMPARE(session.history.undoCount(), steps);
session.setBrushError(std::nullopt);
// A thumbnail failing after the warp opens no transaction.
session.beginTransform();
session.beginDistort();
session.previewCorners({QPointF(10, 10), QPointF(22, 10), QPointF(20, 20), QPointF(10, 20)});
failNextScale = true;
session.commitTransform();
QVERIFY(!failNextScale);
QVERIFY(session.brushError().has_value());
QVERIFY(!session.transformEdit().has_value() && session.canUndo());
QCOMPARE(session.history.undoCount(), steps);
QCOMPARE(layerWith(session, id).mask.value().asset.size(), QSize(10, 10));
session.setBrushError(std::nullopt);
session.selectLayer(id);
session.toggleLayerVisibility(id);
QCOMPARE(session.history.undoCount(), steps + 1);
session.toggleLayerVisibility(id);
// One covering its layer stays covering it, resampled.
const QUuid other = insertAt(session, red(20, 20), QPointF(50, 30));
rewrite(session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, other, LayerMask::assetFrom(block));
record(snapshot, other).maskLinked = false;
});
session.selectLayerTarget(other, true);
session.beginTransform();
session.beginDistort();
session.commitTransform();
QCOMPARE(session.history.undoName(), QString("Distort Layer Mask"));
QCOMPARE(layerWith(session, other).mask.value().placement, std::nullopt);
QCOMPARE(layerWith(session, other).mask.value().asset.size(), QSize(20, 20));
QVERIFY(layerWith(session, other).mask.value().asset.identity() != LayerMask::assetFrom(block).identity());
}
QTEST_GUILESS_MAIN(DistortMaskTests)
#include "DistortMaskTests.moc"