A native Qt6/C++ desktop client for the gallery's POST /upload API, in desktop/ with its own flake (kept separate from the Go server module the same way android/ is). Single-photo parity with the Android app: - multipart upload with preemptive HTTP Basic auth and retry-aware outcome classification (uploadclient) - JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF orientation baked in, and a quality/scale valve for the 20M nginx cap; HEIC decodes via the qtimageformats plugin (imageprep) - base URL / username in QSettings, password in the system keychain via qtkeychain; https required and a trailing /upload stripped (settings) - settings and post screens in a QStackedWidget (mainwindow) Native Wayland or xcb is selected at runtime. Bulk upload, EXIF capture-date prefill, and moving image prep off the UI thread are left as follow-ups.
238 lines
7.6 KiB
C++
238 lines
7.6 KiB
C++
#include "imageprep.h"
|
||
|
||
#include <QBuffer>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QImage>
|
||
#include <QImageReader>
|
||
#include <QImageWriter>
|
||
|
||
namespace {
|
||
// nginx is configured with client_max_body_size 20M; leave headroom.
|
||
constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024;
|
||
constexpr int kJpegQuality = 92;
|
||
const int kQualityLadder[] = {92, 85, 75};
|
||
|
||
// Encodes to JPEG in memory at the given quality.
|
||
QByteArray encodeJpeg(const QImage &image, int quality)
|
||
{
|
||
QByteArray out;
|
||
QBuffer buffer(&out);
|
||
buffer.open(QIODevice::WriteOnly);
|
||
QImageWriter writer(&buffer, "jpeg");
|
||
writer.setQuality(quality);
|
||
writer.write(image);
|
||
return out;
|
||
}
|
||
|
||
// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via
|
||
// the qtimageformats plugin.
|
||
QImage decodeOriented(const QString &path)
|
||
{
|
||
QImageReader reader(path);
|
||
reader.setAutoTransform(true);
|
||
return reader.read();
|
||
}
|
||
} // namespace
|
||
|
||
ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir,
|
||
const QString &baseName)
|
||
{
|
||
Prepared result;
|
||
|
||
QFile in(sourcePath);
|
||
if (!in.open(QIODevice::ReadOnly)) {
|
||
result.error = QStringLiteral("Could not read the selected image");
|
||
return result;
|
||
}
|
||
const QByteArray source = in.readAll();
|
||
in.close();
|
||
|
||
QStringList notes;
|
||
|
||
// A rotation flag cannot survive metadata stripping, so bake it into the
|
||
// pixels instead, which costs a re-encode.
|
||
QImageReader orientationReader(sourcePath);
|
||
const bool mustRotate =
|
||
orientationReader.transformation() != QImageIOHandler::TransformationNone;
|
||
|
||
QByteArray bytes;
|
||
QString extension;
|
||
|
||
if (!mustRotate && isJpeg(source)) {
|
||
bytes = stripJpegSegments(source);
|
||
extension = QStringLiteral("jpg");
|
||
} else if (!mustRotate && isPng(source)) {
|
||
bytes = stripPngChunks(source);
|
||
extension = QStringLiteral("png");
|
||
}
|
||
|
||
if (bytes.isEmpty()) {
|
||
// Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker
|
||
// structure did not parse.
|
||
const QImage image = decodeOriented(sourcePath);
|
||
if (image.isNull()) {
|
||
result.error = QStringLiteral("Could not decode the selected image");
|
||
return result;
|
||
}
|
||
if (mustRotate)
|
||
notes << QStringLiteral("rotated to match its EXIF orientation");
|
||
else if (!isJpeg(source) && !isPng(source))
|
||
notes << QStringLiteral("converted to JPEG");
|
||
bytes = encodeJpeg(image, kJpegQuality);
|
||
extension = QStringLiteral("jpg");
|
||
}
|
||
|
||
// Size valve. Only trips on genuinely huge originals; quality is stepped
|
||
// before touching resolution.
|
||
if (bytes.size() > kMaxUploadBytes) {
|
||
QImage image = decodeOriented(sourcePath);
|
||
if (image.isNull()) {
|
||
result.error = QStringLiteral("Could not decode the selected image");
|
||
return result;
|
||
}
|
||
QByteArray shrunk;
|
||
for (int quality : kQualityLadder) {
|
||
const QByteArray attempt = encodeJpeg(image, quality);
|
||
if (attempt.size() <= kMaxUploadBytes) {
|
||
shrunk = attempt;
|
||
notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality);
|
||
break;
|
||
}
|
||
}
|
||
if (shrunk.isEmpty()) {
|
||
double factor = 1.0;
|
||
while (factor > 0.2) {
|
||
factor *= 0.75;
|
||
const QImage scaled = image.scaledToWidth(
|
||
qMax(1, int(image.width() * factor)), Qt::SmoothTransformation);
|
||
const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]);
|
||
if (attempt.size() <= kMaxUploadBytes) {
|
||
shrunk = attempt;
|
||
notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit")
|
||
.arg(scaled.width()).arg(scaled.height());
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (shrunk.isEmpty()) {
|
||
result.error = QStringLiteral("Image is too large to upload even after downscaling");
|
||
return result;
|
||
}
|
||
bytes = shrunk;
|
||
extension = QStringLiteral("jpg");
|
||
}
|
||
|
||
const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension);
|
||
QFile out(outPath);
|
||
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) {
|
||
result.error = QStringLiteral("Could not write the prepared image");
|
||
return result;
|
||
}
|
||
out.close();
|
||
|
||
result.filePath = outPath;
|
||
result.note = notes.join(QStringLiteral("; "));
|
||
return result;
|
||
}
|
||
|
||
bool ImagePrep::isJpeg(const QByteArray &b)
|
||
{
|
||
return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF;
|
||
}
|
||
|
||
bool ImagePrep::isPng(const QByteArray &b)
|
||
{
|
||
return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E
|
||
&& quint8(b[3]) == 0x47;
|
||
}
|
||
|
||
// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data
|
||
// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would
|
||
// shift the rendered colours. Returns an empty array if the marker structure
|
||
// does not parse, so the caller falls back to a re-encode.
|
||
QByteArray ImagePrep::stripJpegSegments(const QByteArray &src)
|
||
{
|
||
const auto u8 = [&](int i) { return quint8(src[i]); };
|
||
QByteArray out;
|
||
out.append(char(0xFF));
|
||
out.append(char(0xD8));
|
||
|
||
int i = 2;
|
||
while (i + 1 < src.size()) {
|
||
if (u8(i) != 0xFF)
|
||
return {};
|
||
const int marker = u8(i + 1);
|
||
|
||
// Standalone markers: no length field, no payload.
|
||
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
|
||
out.append(char(0xFF));
|
||
out.append(char(marker));
|
||
i += 2;
|
||
continue;
|
||
}
|
||
|
||
// Start of scan: entropy-coded data runs to the end of the file.
|
||
if (marker == 0xDA) {
|
||
out.append(src.mid(i));
|
||
return out;
|
||
}
|
||
|
||
if (i + 3 >= src.size())
|
||
return {};
|
||
const int length = (u8(i + 2) << 8) | u8(i + 3);
|
||
if (length < 2 || i + 2 + length > src.size())
|
||
return {};
|
||
|
||
const bool isExifOrXmp = marker == 0xE1;
|
||
const bool isComment = marker == 0xFE;
|
||
if (!isExifOrXmp && !isComment)
|
||
out.append(src.mid(i, 2 + length));
|
||
|
||
i += 2 + length;
|
||
}
|
||
return {};
|
||
}
|
||
|
||
// Drops the metadata chunks, keeps everything else including the critical
|
||
// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no
|
||
// CRC needs recomputing.
|
||
QByteArray ImagePrep::stripPngChunks(const QByteArray &src)
|
||
{
|
||
static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"};
|
||
const auto u8 = [&](int i) { return quint8(src[i]); };
|
||
if (src.size() < 8)
|
||
return {};
|
||
|
||
QByteArray out;
|
||
out.append(src.mid(0, 8));
|
||
|
||
int i = 8;
|
||
while (i + 8 <= src.size()) {
|
||
const qint64 length =
|
||
(qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3));
|
||
if (length < 0)
|
||
return {};
|
||
|
||
const QByteArray type = src.mid(i + 4, 4);
|
||
const qint64 total = 12 + length;
|
||
if (i + total > src.size())
|
||
return {};
|
||
|
||
bool dropped = false;
|
||
for (const QByteArray &d : drop) {
|
||
if (type == d) {
|
||
dropped = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!dropped)
|
||
out.append(src.mid(i, int(total)));
|
||
i += int(total);
|
||
|
||
if (type == "IEND")
|
||
return out;
|
||
}
|
||
return {};
|
||
}
|