From 3e1cc8af855b307c4d8edc64987acd43633e89d1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:10:24 +0200 Subject: [PATCH] Map avatar crop frames using full original dimensions on both clients --- .../chat/ui/components/AvatarCropDialog.kt | 31 ++++++++++--------- tools/chatgui/src/avatars/avatarcrop.cpp | 19 +++++++----- tools/chatgui/tests/test_avatars.cpp | 9 +++++- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt index afd930f1..63136077 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt @@ -36,6 +36,7 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro val context=LocalContext.current var source by remember(uri) { mutableStateOf(null) } var bitmap by remember(uri) { mutableStateOf(null) } + var dimensions by remember(uri) { mutableStateOf(IntSize.Zero) } var failed by remember(uri) { mutableStateOf(false) } var handedOver by remember(uri) { mutableStateOf(false) } var zoom by rememberSaveable(uri.toString()) { mutableStateOf(1f) } @@ -53,9 +54,9 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro val (w,h)=PhotoUtil.orientedDimensions(file.path) require(w>0 && h>0 && w.toLong()*h<=16L*1024*1024) { "original exceeds 16 megapixels" } var sample=1; while (maxOf(w,h)/sample>1600) sample*=2 - requireNotNull(PhotoUtil.decodeOriented(file.path,sample)) { "cannot decode editor image" } + requireNotNull(PhotoUtil.decodeOriented(file.path,sample)) { "cannot decode editor image" } to IntSize(w,h) } - source=copied; bitmap=image + source=copied; bitmap=image.first; dimensions=image.second } catch (e: Exception) { withContext(NonCancellable+Dispatchers.IO) { copied?.let { if (!it.delete()) LogManager.addLog("WARN","Avatar","Cannot remove editor source=$it") } @@ -77,25 +78,26 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro if (bitmap==null && !failed) LinearProgressIndicator(Modifier.fillMaxWidth()) if (failed) Text(stringResource(R.string.avatar_load_failed),color=MaterialTheme.colorScheme.error) val image=bitmap + val photoW=dimensions.width; val photoH=dimensions.height val side=minOf(bounds.width,bounds.height)*0.8f - val scale=if (image==null) 1f else maxOf(side/image.width,side/image.height)*zoom - val mx=if (image==null) 0f else ((image.width*scale-side)/2).coerceAtLeast(0f) - val my=if (image==null) 0f else ((image.height*scale-side)/2).coerceAtLeast(0f) + val scale=if (image==null) 1f else maxOf(side/photoW,side/photoH)*zoom + val mx=if (image==null) 0f else ((photoW*scale-side)/2).coerceAtLeast(0f) + val my=if (image==null) 0f else ((photoH*scale-side)/2).coerceAtLeast(0f) val dx=ox.coerceIn(-mx,mx); val dy=oy.coerceIn(-my,my) Canvas(Modifier.weight(1f).fillMaxWidth().onSizeChanged { bounds=it } .pointerInput(image,bounds) { if (image!=null) detectTransformGestures { _,pan,factor,_ -> val old=zoom; zoom=(zoom*factor).coerceIn(1f,16f) - val s=maxOf(side/image.width,side/image.height)*zoom - ox=(ox*zoom/old+pan.x).coerceIn(-(image.width*s-side)/2,(image.width*s-side)/2) - oy=(oy*zoom/old+pan.y).coerceIn(-(image.height*s-side)/2,(image.height*s-side)/2) + val s=maxOf(side/photoW,side/photoH)*zoom + ox=(ox*zoom/old+pan.x).coerceIn(-(photoW*s-side)/2,(photoW*s-side)/2) + oy=(oy*zoom/old+pan.y).coerceIn(-(photoH*s-side)/2,(photoH*s-side)/2) } }) { drawRect(Color.Black) if (image!=null && side>0) { - drawImage(image.asImageBitmap(),dstOffset=IntOffset(((size.width-image.width*scale)/2+dx).roundToInt(), - ((size.height-image.height*scale)/2+dy).roundToInt()), - dstSize=IntSize((image.width*scale).roundToInt(),(image.height*scale).roundToInt())) + drawImage(image.asImageBitmap(),dstOffset=IntOffset(((size.width-photoW*scale)/2+dx).roundToInt(), + ((size.height-photoH*scale)/2+dy).roundToInt()), + dstSize=IntSize((photoW*scale).roundToInt(),(photoH*scale).roundToInt())) val x=(size.width-side)/2; val y=(size.height-side)/2; val shade=Color.Black.copy(alpha=0.6f) drawRect(shade,size=Size(size.width,y)); drawRect(shade,Offset(0f,y+side),Size(size.width,y)) drawRect(shade,Offset(0f,y),Size(x,side)); drawRect(shade,Offset(x+side,y),Size(x,side)) @@ -105,10 +107,9 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro Row(Modifier.fillMaxWidth(),horizontalArrangement=Arrangement.End) { TextButton(onClick=onCancel) { Text(stringResource(R.string.cancel)) } TextButton(enabled=image!=null && source!=null && side>0,onClick={ - val selected=requireNotNull(image) - val crop=AvatarCrop(((selected.width*scale-side-2*dx)/(2*scale*selected.width)).toDouble(), - ((selected.height*scale-side-2*dy)/(2*scale*selected.height)).toDouble(), - side.toDouble()/scale/selected.width,side.toDouble()/scale/selected.height) + val crop=AvatarCrop(((photoW*scale-side-2*dx)/(2*scale*photoW)).toDouble(), + ((photoH*scale-side-2*dy)/(2*scale*photoH)).toDouble(), + side.toDouble()/scale/photoW,side.toDouble()/scale/photoH) handedOver=true; onConfirm(requireNotNull(source),crop) }) { Text(stringResource(R.string.avatar_upload)) } } diff --git a/tools/chatgui/src/avatars/avatarcrop.cpp b/tools/chatgui/src/avatars/avatarcrop.cpp index 0c0ec5e8..77cda0a6 100644 --- a/tools/chatgui/src/avatars/avatarcrop.cpp +++ b/tools/chatgui/src/avatars/avatarcrop.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -22,24 +23,26 @@ namespace { class CropView : public QWidget { public: QImage image; + QSize originalSize; explicit CropView(QWidget* parent) : QWidget(parent) { setMinimumSize(320, 320); setCursor(Qt::OpenHandCursor); } QRectF frame() const { const double side = qMin(width(), height()) * 0.8; return {(width()-side)/2, (height()-side)/2, side, side}; } - double scale() const { return qMax(frame().width()/image.width(), frame().height()/image.height()) * zoom; } + double scale() const { return qMax(frame().width()/originalSize.width(), frame().height()/originalSize.height()) * zoom; } void clamp() { if (image.isNull()) return; - const double s = scale(), mx = (image.width()*s-frame().width())/2, my = (image.height()*s-frame().height())/2; + const double s = scale(), mx = (originalSize.width()*s-frame().width())/2, my = (originalSize.height()*s-frame().height())/2; offset = {qBound(-mx, offset.x(), mx), qBound(-my, offset.y(), my)}; } QRectF selection() const { const double s = scale(); - const QPointF top = QPointF(width()/2.0,height()/2.0) + offset - QPointF(image.width()*s/2, image.height()*s/2); + const QPointF top = QPointF(width()/2.0,height()/2.0) + offset - QPointF(originalSize.width()*s/2, originalSize.height()*s/2); const QRectF f = frame(); - return {(f.x()-top.x())/s/image.width(), (f.y()-top.y())/s/image.height(), f.width()/s/image.width(), f.height()/s/image.height()}; + return {(f.x()-top.x())/s/originalSize.width(), (f.y()-top.y())/s/originalSize.height(), + f.width()/s/originalSize.width(), f.height()/s/originalSize.height()}; } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.fillRect(rect(), QColor(20,20,22)); if (image.isNull()) return; - clamp(); const double s = scale(); const QSizeF size(image.width()*s, image.height()*s); + clamp(); const double s = scale(); const QSizeF size(originalSize.width()*s, originalSize.height()*s); p.setRenderHint(QPainter::SmoothPixmapTransform); p.drawImage(QRectF(QPointF(width()/2.0,height()/2.0)+offset-QPointF(size.width()/2,size.height()/2), size), image); QPainterPath mask; mask.addRect(rect()); mask.addRect(frame()); p.fillPath(mask, QColor(0,0,0,150)); @@ -72,17 +75,19 @@ bool selectAvatarCrop(const QString& source, QRectF& crop, QWidget* parent) { QThreadPool pool; pool.setMaxThreadCount(1); pool.start(QRunnable::create([source, &dialog, view, status, buttons] { QImageReader reader(source); reader.setAutoTransform(true); const QSize size = reader.size(); QImage image; + QSize orientedSize=size; + if (reader.transformation() & QImageIOHandler::TransformationRotate90) orientedSize.transpose(); if (QFileInfo(source).size() <= AvatarCache::OriginalMaxBytes && size.isValid() && qint64(size.width())*size.height() <= 16*1024*1024 && (reader.format()=="jpeg" || reader.format()=="png" || reader.format()=="webp")) { reader.setScaledSize(size.scaled(1600,1600,Qt::KeepAspectRatio)); image = reader.read(); } - QMetaObject::invokeMethod(&dialog, [view, image, status, buttons] { + QMetaObject::invokeMethod(&dialog, [view, image, orientedSize, status, buttons] { if (image.isNull()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: editor cannot decode selected source"); status->setText(QCoreApplication::translate("ChatGui", "Cannot load avatar image")); return; } - view->image=image; view->update(); buttons->button(QDialogButtonBox::Ok)->setEnabled(true); + view->image=image; view->originalSize=orientedSize; view->update(); buttons->button(QDialogButtonBox::Ok)->setEnabled(true); }, Qt::QueuedConnection); })); const int result=dialog.exec(); pool.waitForDone(); diff --git a/tools/chatgui/tests/test_avatars.cpp b/tools/chatgui/tests/test_avatars.cpp index 0f367a1d..261bd8a7 100644 --- a/tools/chatgui/tests/test_avatars.cpp +++ b/tools/chatgui/tests/test_avatars.cpp @@ -138,9 +138,16 @@ private slots: automate.start(); QRectF crop; QVERIFY(selectAvatarCrop(source,crop,nullptr)); automate.stop(); QVERIFY(qAbs(crop.x())<0.001); QVERIFY(qAbs(crop.y())<0.001); QVERIFY(qAbs(crop.width()-0.5)<0.001); QVERIFY(qAbs(crop.height()-1)<0.001); + const QString panorama=directory.path()+"/panorama.png"; + QImage wide(14003,1001,QImage::Format_RGB32); wide.fill(Qt::blue); QVERIFY(wide.save(panorama)); + automate.start(); QVERIFY(selectAvatarCrop(panorama,crop,nullptr)); automate.stop(); + const auto prepared=AvatarCache::prepare(panorama,directory.path(),crop); + QVERIFY2(prepared.error.isEmpty(),qPrintable(prepared.error)); + QVERIFY(qAbs(prepared.record.crop.width()*14003-prepared.record.crop.height()*1001)<=1); + const int preparedFiles=QDir(directory.path()).entryList({"*.json","*.webp"},QDir::Files).size(); QTimer::singleShot(10,[] { if (auto* dialog=qobject_cast(QApplication::activeModalWidget())) dialog->reject(); }); QVERIFY(!selectAvatarCrop(source,crop,nullptr)); - QCOMPARE(QDir(directory.path()).entryList({"*.json","*.webp"},QDir::Files).size(),0); + QCOMPARE(QDir(directory.path()).entryList({"*.json","*.webp"},QDir::Files).size(),preparedFiles); } void uploadReplicateCacheRestartAndRemove() {