OmaPhoto 1.0.0: the Linux port of Compositor
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:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,173 @@
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Document/LayerEffects+Renderer.h"
|
||||
#include "Document/LayerGroups.h"
|
||||
#include "Document/LayerMask.h"
|
||||
#include "Document/LiveLayerMask.h"
|
||||
#include "Logging.h"
|
||||
#include "Rendering/LayerRenderer.h"
|
||||
#include "Rendering/LiveMaskRenderer.h"
|
||||
#include <QBuffer>
|
||||
#include <QColorSpace>
|
||||
#include <QImageReader>
|
||||
#include <QImageWriter>
|
||||
#include <QSaveFile>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
const char *description(ExportError::Kind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case ExportError::Kind::tooLarge:
|
||||
return "Image export supports canvases up to 100 megapixels and 30,000 pixels per side.";
|
||||
case ExportError::Kind::render:
|
||||
return "The canvas could not be rendered. Try a smaller canvas.";
|
||||
case ExportError::Kind::encode:
|
||||
return "The image could not be encoded.";
|
||||
}
|
||||
throw std::logic_error("unknown ExportError kind");
|
||||
}
|
||||
|
||||
// Quality runs 0 to 100; -1 means the format's own.
|
||||
QByteArray encode(QImage image, const char *format, int quality, double resolution)
|
||||
{
|
||||
const int dotsPerMeter = qRound(resolution / 0.0254);
|
||||
image.setDotsPerMeterX(dotsPerMeter);
|
||||
image.setDotsPerMeterY(dotsPerMeter);
|
||||
image.setColorSpace(QColorSpace::SRgb);
|
||||
QByteArray data;
|
||||
QBuffer buffer(&data);
|
||||
QImageWriter writer(&buffer, format);
|
||||
writer.setQuality(quality);
|
||||
if (!writer.write(image))
|
||||
throw ExportError(ExportError::Kind::encode);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {}
|
||||
|
||||
ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot)
|
||||
try {
|
||||
const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height;
|
||||
if (width < 1 || width > 30'000 || height < 1 || height > 30'000 || width * height > 100'000'000)
|
||||
throw ExportError(ExportError::Kind::tooLarge);
|
||||
QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied);
|
||||
if (image.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
image.fill(0);
|
||||
const std::vector<ProjectLayerRecord> &layers = snapshot.manifest.layers;
|
||||
std::map<QUuid, ProjectLayerRecord> records;
|
||||
for (const ProjectLayerRecord &layer : layers) {
|
||||
if ((layer.imageFile && !snapshot.images.contains(layer.id)) || (layer.maskFile && !snapshot.masks.contains(layer.id)))
|
||||
throw ProjectError(ProjectError::Kind::missingImage);
|
||||
records.insert({layer.id, layer});
|
||||
}
|
||||
LiveMaskGraph::validate(layers);
|
||||
const auto parent = [&](QUuid id) { return records.contains(id) ? records.at(id).parentID : std::nullopt; };
|
||||
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id).maskSourceID : std::nullopt; },
|
||||
[&](QUuid id, QPainter &target, const QImage &clip) {
|
||||
const auto asset = snapshot.images.find(id);
|
||||
if (!records.contains(id) || asset == snapshot.images.end())
|
||||
return;
|
||||
const ProjectLayerRecord &layer = records.at(id);
|
||||
const QImage pixels = asset->second.image();
|
||||
const std::optional<LayerMask> mask = snapshot.mask(layer);
|
||||
const std::optional<QImage> shown =
|
||||
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
|
||||
const double opacity = layer.opacity.value_or(1);
|
||||
const LayerBlendMode mode = layer.blendMode.value_or(LayerBlendMode::normal);
|
||||
// With effects the mask is in their image already.
|
||||
if (const auto effects = LayerEffectsRenderer::cached(pixels, shown, layer.effects)) {
|
||||
const LayerTransform grown = LayerEffectsRenderer::placed(layer.transform, effects->image, effects->inset);
|
||||
LayerRenderer::draw(effects->image, grown, grown.center(), target, {.opacity = opacity, .blendMode = mode, .clip = clip});
|
||||
return;
|
||||
}
|
||||
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), target,
|
||||
{.opacity = opacity, .blendMode = mode, .mask = shown.value_or(QImage()), .clip = clip});
|
||||
});
|
||||
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity.value_or(1); };
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &context, QImage &coverage) {
|
||||
const ProjectLayerRecord &layer = records.at(id);
|
||||
const std::optional<LayerMask> mask = snapshot.mask(layer);
|
||||
if (const std::optional<QImage> image = mask ? mask->enabledImage() : std::nullopt)
|
||||
FolderMaskClip{*image, layer.transform}.apply(layer.transform.center(), context, coverage);
|
||||
};
|
||||
std::vector<QUuid> visible;
|
||||
for (const ProjectLayerRecord &layer : LayerHierarchy::visibleLayers(layers))
|
||||
visible.push_back(layer.id);
|
||||
live.prepareStacks(visible, parent, [&](QUuid id) { return records.at(id).blendMode.value_or(LayerBlendMode::normal); });
|
||||
{
|
||||
QPainter painter(&image);
|
||||
FolderMaskClip::draw(visible, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> {
|
||||
const std::optional<LayerMask> mask = records.contains(id) ? snapshot.mask(records.at(id)) : std::nullopt;
|
||||
const std::optional<QImage> enabled = mask ? mask->enabledImage() : std::nullopt;
|
||||
if (!enabled)
|
||||
return std::nullopt;
|
||||
const FolderMaskClip clip{*enabled, records.at(id).transform};
|
||||
return [clip](const QPainter &context, QImage &coverage) { clip.apply(clip.transform.center(), context, coverage); };
|
||||
}, painter, [&](QUuid id, const QImage &clip) { live.drawComposite(id, painter, clip); });
|
||||
}
|
||||
qCInfo(lcIO) << "rendered" << width << "x" << height << "from" << visible.size() << "visible layers";
|
||||
return {image, snapshot.manifest.resolution.value_or(72)};
|
||||
} catch (const std::bad_alloc &) {
|
||||
// Memory that runs out fails the render, as contexts do.
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
|
||||
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
|
||||
{
|
||||
const ExportRaster raster = render(snapshot);
|
||||
return encode(raster.image, "png", -1, raster.resolution);
|
||||
}
|
||||
|
||||
JPEGResult ImageExporter::jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled)
|
||||
try {
|
||||
const auto check = [&cancelled] {
|
||||
if (cancelled && cancelled())
|
||||
throw CancellationError();
|
||||
};
|
||||
check();
|
||||
// JPEG has no alpha: the image lies over the matte.
|
||||
QImage flattened(raster.image.size(), QImage::Format_RGB888);
|
||||
if (flattened.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
flattened.fill(QColor::fromRgbF(float(options.red), float(options.green), float(options.blue)));
|
||||
{
|
||||
QPainter painter(&flattened);
|
||||
painter.drawImage(QRectF(flattened.rect()), raster.image);
|
||||
}
|
||||
check();
|
||||
QByteArray data = encode(flattened, "jpeg", qRound(std::clamp(options.quality, 0.0, 1.0) * 100), raster.resolution);
|
||||
check();
|
||||
// The preview shows what the file holds, compression and all.
|
||||
QBuffer buffer(&data);
|
||||
QImageReader reader(&buffer, "jpeg");
|
||||
const QSize size = reader.size();
|
||||
// A thin image keeps one pixel across.
|
||||
if (std::max(size.width(), size.height()) > 1000)
|
||||
reader.setScaledSize(size.scaled(1000, 1000, Qt::KeepAspectRatio).expandedTo(QSize(1, 1)));
|
||||
const QImage preview = reader.read();
|
||||
if (preview.isNull())
|
||||
throw ExportError(ExportError::Kind::encode);
|
||||
return {data, preview};
|
||||
} catch (const std::bad_alloc &) {
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
|
||||
void ImageExporter::exportPNG(const ProjectSnapshot &snapshot, const QString &path)
|
||||
{
|
||||
write(pngData(snapshot), path);
|
||||
}
|
||||
|
||||
void ImageExporter::write(const QByteArray &data, const QString &path)
|
||||
{
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
|
||||
qCWarning(lcIO) << "could not write" << path << ":" << file.errorString();
|
||||
throw std::runtime_error(file.errorString().toStdString());
|
||||
}
|
||||
qCInfo(lcIO) << "wrote" << data.size() << "bytes to" << path;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user