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

+353
View File
@@ -0,0 +1,353 @@
#include "Document/BrushStroke.h"
#include "Document/Distort.h"
#include "IO/ImageExporter.h"
#include "SessionFixtures.h"
#include <QtTest>
// Free distortion: the warp, the session's edit, masks along.
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();
}
bool near(QPointF a, QPointF b)
{
return std::abs(a.x() - b.x()) < 1e-6 && std::abs(a.y() - b.y()) < 1e-6;
}
}
class DistortTests : public QObject {
Q_OBJECT
private slots:
void cornersRunClockwiseFromTheTopLeft();
void perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused();
void distortingWarpsTheLayerIntoTheShapeAsOneUndoStep();
void distortedLayerIsTrimmedToItsVisiblePixels();
void foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners();
void carriedCornersAndWarpedMasks();
void nudgesGroupsAndRefusals();
void mapPathCarriesCurvesFlipsAndTheFillRule();
};
void DistortTests::cornersRunClockwiseFromTheTopLeft()
{
const LayerTransform upright{.origin = {10, 20}, .size = {40, 30}};
QCOMPARE(DistortWarp::corners(upright), (Corners{QPointF(10, 20), QPointF(50, 20), QPointF(50, 50), QPointF(10, 50)}));
// Turned, each corner is where the transform puts it.
const LayerTransform turned{.origin = {10, 20}, .size = {40, 30}, .rotation = 37, .flipX = true};
const Corners corners = DistortWarp::corners(turned);
const std::array<QPointF, 4> unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)};
for (size_t index = 0; index < 4; ++index)
QVERIFY(near(corners[index], turned.point(unit[index])));
QVERIFY(!near(corners[1], corners[0]));
}
void DistortTests::perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused()
{
const QTransform map = DistortWarp::homography(shape);
const Corners unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)};
for (size_t index = 0; index < 4; ++index)
QVERIFY2(near(map.map(unit[index]), shape[index]), qPrintable(QString("corner %1").arg(index)));
// The middle lands where the diagonals cross, a perspective.
const QPointF middle = map.map(QPointF(0.5, 0.5));
QVERIFY(std::abs(middle.x() - 170.0 / 7) < 1e-9 && std::abs(middle.y() - 170.0 / 7) < 1e-9);
// A slanted bottom too: both perspective terms at work.
const Corners slanted = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 40), QPointF(10, 30)};
const QTransform slant = DistortWarp::homography(slanted);
for (size_t index = 0; index < 4; ++index)
QVERIFY(near(slant.map(unit[index]), slanted[index]));
const QPointF crossing = slant.map(QPointF(0.5, 0.5));
QVERIFY(std::abs(crossing.x() - 390.0 / 19) < 1e-9 && std::abs(crossing.y() - 490.0 / 19) < 1e-9);
QVERIFY(DistortWarp::isUsable(shape) && DistortWarp::isConvex(shape));
// A bow tie folds: usable as two triangles, no perspective.
const Corners bowTie = {shape[0], shape[2], shape[1], shape[3]};
const Corners collapsed = {shape[0], shape[0], shape[2], shape[3]};
QVERIFY(DistortWarp::isUsable(bowTie) && !DistortWarp::isConvex(bowTie));
QVERIFY(!DistortWarp::isUsable(collapsed));
// A flat half is refused; a straight side, no perspective.
QVERIFY(!DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(20, 20)}));
QVERIFY(DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)}));
QVERIFY(!DistortWarp::isConvex({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)}));
QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(std::nan(""), 10), shape[2], shape[3]}));
QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(2'000'000, 10), shape[2], shape[3]}));
// A dent is usable but no perspective: two triangles.
const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)};
QVERIFY(DistortWarp::isUsable(dented) && !DistortWarp::isConvex(dented));
// Mirrored winding still counts as convex.
QVERIFY(DistortWarp::isConvex({shape[1], shape[0], shape[3], shape[2]}));
}
void DistortTests::distortingWarpsTheLayerIntoTheShapeAsOneUndoStep()
{
EditorSession session;
session.createDocument(100, 60);
const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10));
session.selectTool(NavigationTool::move);
session.beginTransform(false);
session.beginDistort();
QVERIFY(session.transformEdit().value().corners.has_value() && session.transformEdit().value().persistent);
// A collapsed corner: ignored.
session.previewCorners({shape[0], shape[0], shape[2], shape[3]});
QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(30, 10));
session.previewCorners(shape);
QCOMPARE(session.transformEdit().value().corners.value(), shape);
const int count = session.history.undoCount();
session.commitTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Distort"));
const LayerTransform transform = layerWith(session, id).transform;
QCOMPARE(transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}}));
QCOMPARE(layerWith(session, id).asset.value().size(), QSize(50, 20));
QCOMPARE(alphaAt(session, 50, 12), 255);
QCOMPARE(alphaAt(session, 15, 25), 255);
QCOMPARE(alphaAt(session, 50, 28), 0);
QCOMPARE(alphaAt(session, 80, 12), 0);
session.undo();
QCOMPARE(layerWith(session, id).transform.size, QSizeF(20, 20));
QCOMPARE(layerWith(session, id).asset.value().size(), QSize(20, 20));
}
void DistortTests::distortedLayerIsTrimmedToItsVisiblePixels()
{
EditorSession session;
session.createDocument(100, 60);
// A 40 by 20 layer, clear except a red square.
QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::transparent);
QPainter painter(&image);
painter.fillRect(QRect(15, 5, 10, 10), Qt::red);
painter.end();
const QUuid id = insertAt(session, image, QPointF(10, 10));
session.selectTool(NavigationTool::move);
session.beginTransform(false);
session.beginDistort();
session.previewCorners({QPointF(10, 10), QPointF(60, 10), QPointF(50, 30), QPointF(10, 30)});
session.commitTransform();
const LayerTransform transform = layerWith(session, id).transform;
// Bounds of 50 by 20; the square's warp is smaller.
QVERIFY2(transform.size.width() < 20 && transform.size.height() <= 12, qPrintable(QString("%1 x %2").arg(transform.size.width()).arg(transform.size.height())));
QVERIFY2(transform.origin.x() >= 20 && transform.origin.y() >= 14, qPrintable(QString("%1, %2").arg(transform.origin.x()).arg(transform.origin.y())));
QCOMPARE(alphaAt(session, 30, 20), 255);
// The trimmed asset is the layer's own size.
QCOMPARE(QSizeF(layerWith(session, id).asset.value().size()), transform.size);
}
void DistortTests::foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners()
{
const LayerTransform placed{.origin = {10, 10}, .size = {20, 20}};
QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {shape[0], shape[0], shape[2], shape[3]}, false));
// A shape too large for a warp.
QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {QPointF(0, 0), QPointF(40'000, 0), QPointF(40'000, 10), QPointF(0, 10)}, false));
const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)};
const DistortWarp::Warped folded = DistortWarp::warp(red(20, 20), placed, dented, false);
// Copied without interpolation: a colour boundary stays sharp.
QImage twoTone = red(20, 20);
QPainter painter(&twoTone);
painter.fillRect(QRect(0, 0, 10, 20), Qt::blue);
painter.end();
const Corners wide = {QPointF(10, 10), QPointF(110, 10), QPointF(20, 20), QPointF(10, 110)};
const DistortWarp::Warped sharp = DistortWarp::warp(twoTone, placed, wide, false);
// The half is sheared: its colour boundary sits at 45.5.
QCOMPARE(sharp.image.pixelColor(43, 0), QColor(Qt::blue));
QCOMPARE(sharp.image.pixelColor(47, 0), QColor(Qt::red));
QCOMPARE(folded.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 50}}));
QCOMPARE(folded.image.size(), QSize(50, 50));
QCOMPARE(folded.image.pixelColor(20, 2).alpha(), 255);
QCOMPARE(folded.image.pixelColor(2, 20).alpha(), 255);
QCOMPARE(folded.image.pixelColor(40, 4).alpha(), 0);
QCOMPARE(folded.image.pixelColor(45, 45).alpha(), 0);
// The halves meet along the diagonal without a seam.
const DistortWarp::Warped seam = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(0, 100)}, false);
for (const int along : {10, 15, 25})
QCOMPARE(seam.image.pixelColor(along, along).alpha(), 255);
QCOMPARE(seam.image.pixelColor(50, 5).alpha(), 255);
QCOMPARE(seam.image.pixelColor(5, 50).alpha(), 255);
QCOMPARE(seam.image.pixelColor(60, 60).alpha(), 0);
// Past a half's edge nothing draws, though its map would.
const DistortWarp::Warped spill = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(10, 5)}, false);
QCOMPARE(spill.image.pixelColor(40, 10).alpha(), 255);
QCOMPARE(spill.image.pixelColor(5, 20).alpha(), 0);
QCOMPARE(spill.image.pixelColor(10, 20).alpha(), 0);
// A centre a sloping edge holds is kept; fractions too.
const DistortWarp::Warped sloping = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false);
QVERIFY(sloping.image.pixelColor(5, 0).alpha() > 200);
QVERIFY(sloping.image.pixelColor(2, 40).alpha() > 200);
// Beside the diagonal each pixel comes from its own half.
QImage patterned(20, 20, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < 20; ++y) {
for (int x = 0; x < 20; ++x)
patterned.setPixelColor(x, y, QColor(12 * x, 12 * y, 0));
}
const DistortWarp::Warped beside = DistortWarp::warp(patterned, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false);
QCOMPARE(beside.image.pixelColor(20, 20), QColor(180, 180, 0));
// A fractional shared edge, met both ways, has no hole.
const DistortWarp::Warped shared = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.1, 0.9), QPointF(100, 0.9), QPointF(20.1, 30.9), QPointF(0.1, 100)}, false);
for (const QPoint hole : {QPoint(4, 7), QPoint(6, 10), QPoint(8, 13)})
QCOMPARE(shared.image.pixelColor(hole).alpha(), 255);
const DistortWarp::Warped fractional = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.4, 0.4), QPointF(60.6, 0.4), QPointF(20.5, 20.5), QPointF(0.4, 60.6)}, false);
QCOMPARE(fractional.image.pixelColor(30, 5).alpha(), 255);
QCOMPARE(fractional.image.pixelColor(5, 30).alpha(), 255);
QCOMPARE(fractional.image.pixelColor(40, 40).alpha(), 0);
for (const int along : {8, 12, 16})
QCOMPARE(fractional.image.pixelColor(along, along).alpha(), 255);
QVERIFY(fractional.image.pixelColor(30, 0).alpha() > 100);
// A row centred on a horizontal top edge is drawn.
const DistortWarp::Warped topEdge = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.5, 0.5), QPointF(100.5, 0.5), QPointF(30, 30), QPointF(0, 100)}, false);
QVERIFY(topEdge.image.pixelColor(50, 0).alpha() > 100);
// Where the halves overlap the second replaces the first.
QImage faint(20, 20, QImage::Format_RGBA8888_Premultiplied);
faint.fill(QColor(128, 0, 0, 128));
const DistortWarp::Warped bowTie = DistortWarp::warp(faint, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(40, 40), QPointF(40, 0), QPointF(0, 40)}, false);
QCOMPARE(bowTie.image.pixelColor(20, 10).alpha(), 128);
QCOMPARE(qAlpha(bowTie.image.pixel(20, 10)), 128);
// A limit shrinks the warp; a flip sends pixels across.
const DistortWarp::Warped small = DistortWarp::warp(twoTone, placed, shape, false, 25);
QCOMPARE(small.image.size(), QSize(25, 10));
QCOMPARE(small.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}}));
QCOMPARE(small.image.pixelColor(2, 2), QColor(Qt::blue));
LayerTransform flipped = placed;
flipped.flipX = true;
const DistortWarp::Warped mirrored = DistortWarp::warp(twoTone, flipped, shape, false);
QCOMPARE(mirrored.image.pixelColor(2, 2), QColor(Qt::red));
QCOMPARE(mirrored.image.pixelColor(40, 2), QColor(Qt::blue));
// The sampling travels with the placement.
LayerTransform nearest = placed;
nearest.sampling = LayerSampling::nearest;
QCOMPARE(DistortWarp::warp(twoTone, nearest, shape, false).transform.sampling, LayerSampling::nearest);
}
void DistortTests::carriedCornersAndWarpedMasks()
{
const LayerTransform box{.origin = {10, 10}, .size = {20, 20}};
// The box lands on the shape; one moved aside, aside.
const Corners same = DistortWarp::carried(box, box, shape);
for (size_t index = 0; index < 4; ++index)
QVERIFY(near(same[index], shape[index]));
const LayerTransform turned{.origin = {10, 10}, .size = {20, 20}, .rotation = 30};
const Corners turnedSame = DistortWarp::carried(turned, turned, shape);
for (size_t index = 0; index < 4; ++index)
QVERIFY(near(turnedSame[index], shape[index]));
const Corners moved = {QPointF(20, 20), QPointF(40, 20), QPointF(40, 40), QPointF(20, 40)};
const Corners aside = DistortWarp::carried({.origin = {15, 15}, .size = {20, 20}}, box, moved);
for (size_t index = 0; index < 4; ++index)
QVERIFY(near(aside[index], moved[index] + QPointF(5, 5)));
// A mask's background fills the shape's surround.
QImage mask(4, 4, QImage::Format_Grayscale8);
mask.fill(200);
const Corners half = {QPointF(0, 0), QPointF(8, 0), QPointF(4, 4), QPointF(0, 4)};
const LayerTransform grid{.origin = {0, 0}, .size = {4, 4}};
const DistortWarp::Warped lit = DistortWarp::warpMask(mask, grid, half, 1);
QCOMPARE(lit.image.size(), QSize(8, 4));
QCOMPARE(lit.image.format(), QImage::Format_Grayscale8);
QCOMPARE(int(lit.image.constScanLine(3)[7]), 255);
QVERIFY(std::abs(int(lit.image.constScanLine(1)[1]) - 200) <= 2);
const DistortWarp::Warped dark = DistortWarp::warpMask(mask, grid, half, 0);
QCOMPARE(int(dark.image.constScanLine(3)[7]), 0);
// A uniform mask covers any shape as it is.
QImage uniform(1, 1, QImage::Format_Grayscale8);
uniform.fill(255);
const DistortWarp::Warped kept = DistortWarp::warpMask(uniform, grid, half, 1);
QCOMPARE(kept.image.cacheKey(), uniform.cacheKey());
QCOMPARE(kept.transform, (LayerTransform{.origin = {0, 0}, .size = {8, 4}}));
}
void DistortTests::nudgesGroupsAndRefusals()
{
EditorSession session;
session.beginDistort();
session.previewCorners(shape);
session.createDocument(100, 60);
const QUuid a = insertAt(session, red(20, 20), QPointF(10, 10));
const QUuid b = insertAt(session, red(10, 10), QPointF(50, 30));
session.selectLayer(a);
// Without an edit or corners, nothing begins or moves.
session.beginDistort();
QVERIFY(!session.transformEdit().has_value());
session.beginTransform();
session.previewCorners(shape);
QVERIFY(!session.transformEdit().value().corners.has_value());
session.beginDistort();
session.beginDistort();
const Corners own = DistortWarp::corners(layerWith(session, a).transform);
QCOMPARE(session.transformEdit().value().corners.value(), own);
// A nudge moves the corners; a new begin keeps them.
session.nudgeLayer(3, -2);
QCOMPARE(session.transformEdit().value().draft.origin, QPointF(13, 8));
QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2));
session.beginDistort();
QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2));
session.cancelTransform();
QCOMPARE(layerWith(session, a).transform.origin, QPointF(10, 10));
// A group takes the box's perspective; an unlinked mask stays.
rewrite(session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, b, coverage());
record(snapshot, b).maskLinked = false;
});
session.selectLayers({a, b}, a);
session.beginTransform();
session.beginDistort();
QCOMPARE(session.displayedMaskPlacement(layerWith(session, b)), std::optional(layerWith(session, b).transform));
const Corners box = session.transformEdit().value().corners.value();
QCOMPARE(box[0], QPointF(10, 10));
QCOMPARE(box[2], QPointF(60, 40));
session.previewCorners({box[0], box[1] + QPointF(20, 0), box[2], box[3]});
QVERIFY(session.distortPreview(layerWith(session, b)).has_value());
session.commitTransform();
QCOMPARE(session.history.undoName(), QString("Distort Layers"));
QVERIFY(layerWith(session, a).transform.size.width() > 20);
QVERIFY(layerWith(session, b).transform.size.width() > 10);
QCOMPARE(layerWith(session, b).transform.origin.x(), 50.0);
}
void DistortTests::mapPathCarriesCurvesFlipsAndTheFillRule()
{
// A 10-pixel layer at 10,10, its box the shape.
const LayerTransform transform{.origin = {10, 10}, .size = {10, 10}};
const QTransform placement = BrushRaster::pixelToDocument(transform, 10, 10);
const Corners box = DistortWarp::corners(transform);
QPainterPath ellipse;
ellipse.addEllipse(QRectF(11, 11, 4, 4));
ellipse.setFillRule(Qt::WindingFill);
const QPainterPath carried = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, box).value();
QCOMPARE(carried.fillRule(), Qt::WindingFill);
QCOMPARE(carried.elementCount(), ellipse.elementCount());
for (int i = 0; i < ellipse.elementCount(); ++i) {
QCOMPARE(carried.elementAt(i).type, ellipse.elementAt(i).type);
QVERIFY(near(carried.elementAt(i), ellipse.elementAt(i)));
}
// A flipped draft mirrors it inside the shape.
LayerTransform flipped = transform;
flipped.flipX = true;
QRectF bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect();
QVERIFY(near(bounds.topLeft(), QPointF(15, 11)) && near(bounds.bottomRight(), QPointF(19, 15)));
flipped = transform;
flipped.flipY = true;
bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect();
QVERIFY(near(bounds.topLeft(), QPointF(11, 15)) && near(bounds.bottomRight(), QPointF(15, 19)));
// A folded shape or an empty grid carries nothing.
const Corners folded = {QPointF(10, 10), QPointF(20, 20), QPointF(20, 10), QPointF(10, 20)};
QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, folded).has_value());
QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(0, 10), transform, box).has_value());
}
QTEST_GUILESS_MAIN(DistortTests)
#include "DistortTests.moc"