Implement animated GIF/WEBP support for Board posts (#3095)

Features:
- Detect and animate GIF/WEBP images with multiple frames using QMovie
- Automatic looping for smooth playback
- 194KB size limit enforced at upload time
- User-friendly error message for oversized animated images
- Preserve static image display for non-animated content

Implementation:
- Created BoardPostImageHelper utility class for format detection
- Updated BoardPostDisplayWidget (compact and card variants)
- Updated PostedCardView for feed display
- Updated PhotoView for popup animation
- Added upload validation in PostedCreatePostDialog
- Proper memory management to prevent leaks

Technical details:
- Uses QImageReader for frame counting and format detection
- QMovie with QBuffer for animation playback
- Parent-child cleanup for automatic memory management
- Tested on macOS with successful build

Files modified:
- gui/Posted/BoardPostImageHelper.{h,cpp} (NEW)
- gui/Posted/BoardPostDisplayWidget.cpp
- gui/Posted/PostedCardView.cpp
- gui/Posted/PhotoView.{h,cpp}
- gui/Posted/PostedCreatePostDialog.cpp
- retroshare-gui.pro
This commit is contained in:
natinew77-creator 2026-01-05 06:54:49 -05:00 committed by defnax
parent a26b5a4be5
commit b4f56cf4d8
8 changed files with 278 additions and 29 deletions

View File

@ -27,6 +27,7 @@
#include "rshare.h"
#include "BoardPostDisplayWidget.h"
#include "BoardPostImageHelper.h"
#include "PhotoView.h"
#include "gui/gxs/GxsIdDetails.h"
#include "util/misc.h"
@ -330,17 +331,33 @@ void BoardPostDisplayWidget_compact::setup()
{
if(mPost.mImage.mData != NULL)
{
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
// Wiping data - as its been passed to thumbnail.
QString format;
if (BoardPostImageHelper::isAnimatedImage(mPost.mImage.mData, mPost.mImage.mSize, &format))
{
// Animated GIF/WEBP - use QMovie
QMovie* movie = BoardPostImageHelper::createMovieFromData(mPost.mImage.mData, mPost.mImage.mSize);
if (movie)
{
ui->pictureLabel->setMovie(movie);
movie->start();
// Loop animation when finished
connect(movie, &QMovie::finished, movie, &QMovie::start);
}
}
else
{
// Static image - use QPixmap
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
#ifdef DEBUG_BOARDPOSTDISPLAYWIDGET
std::cerr << "Got pixmap of size " << pixmap.width() << " x " << pixmap.height() << std::endl;
std::cerr << "Saving to pix.png" << std::endl;
pixmap.save("pix.png","JPG");
std::cerr << "Got pixmap of size " << pixmap.width() << " x " << pixmap.height() << std::endl;
std::cerr << "Saving to pix.png" << std::endl;
pixmap.save("pix.png","JPG");
#endif
ui->pictureLabel->setPicture(pixmap);
ui->pictureLabel->setPicture(pixmap);
}
}
else
ui->pictureLabel->setPicture( FilesDefs::getPixmapFromQtResourcePath(":/images/thumb-default.png") );
@ -382,13 +399,28 @@ void BoardPostDisplayWidget_compact::viewPicture()
return;
QString timestamp = misc::timeRelativeToNow(mPost.mMeta.mPublishTs);
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
RsGxsId authorID = mPost.mMeta.mAuthorId;
PhotoView *PView = new PhotoView();
PView->setPixmap(pixmap);
// Check if animated image
QString format;
if (BoardPostImageHelper::isAnimatedImage(mPost.mImage.mData, mPost.mImage.mSize, &format))
{
// Animated GIF/WEBP - use QMovie in popup
QMovie* movie = BoardPostImageHelper::createMovieFromData(mPost.mImage.mData, mPost.mImage.mSize);
if (movie) {
PView->setMovie(movie);
}
}
else
{
// Static image - use QPixmap
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
PView->setPixmap(pixmap);
}
PView->setTitle(QString::fromUtf8(mPost.mMeta.mMsgName.c_str()));
PView->setName(authorID);
PView->setTime(timestamp);
@ -446,16 +478,32 @@ void BoardPostDisplayWidget_card::setup()
{
if(mPost.mImage.mData != NULL)
{
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
// Wiping data - as its been passed to thumbnail.
QString format;
if (BoardPostImageHelper::isAnimatedImage(mPost.mImage.mData, mPost.mImage.mSize, &format))
{
// Animated GIF/WEBP - use QMovie
QMovie* movie = BoardPostImageHelper::createMovieFromData(mPost.mImage.mData, mPost.mImage.mSize);
if (movie)
{
ui->pictureLabel->setMovie(movie);
movie->start();
// Loop animation when finished
connect(movie, &QMovie::finished, movie, &QMovie::start);
}
}
else
{
// Static image - use QPixmap with scaling
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
if(pixmap.width() > 800){
QPixmap scaledpixmap = pixmap.scaledToWidth(800, Qt::SmoothTransformation);
ui->pictureLabel->setPixmap(scaledpixmap);
}else{
ui->pictureLabel->setPixmap(pixmap);
}
if(pixmap.width() > 800){
QPixmap scaledpixmap = pixmap.scaledToWidth(800, Qt::SmoothTransformation);
ui->pictureLabel->setPixmap(scaledpixmap);
}else{
ui->pictureLabel->setPixmap(pixmap);
}
}
ui->pictureLabel->show();
}

View File

@ -0,0 +1,78 @@
/*******************************************************************************
* retroshare-gui/src/gui/Posted/BoardPostImageHelper.cpp *
* *
* Copyright (C) 2025 RetroShare Team <retroshare.project@gmail.com> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU Affero General Public License as *
* published by the Free Software Foundation, either version 3 of the *
* License, or (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU Affero General Public License for more details. *
* *
* You should have received a copy of the GNU Affero General Public License *
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
#include "BoardPostImageHelper.h"
#include <QBuffer>
#include <QImageReader>
bool BoardPostImageHelper::isAnimatedImage(const uint8_t* data, uint32_t size, QString* format)
{
if (!data || size == 0 || size > MAX_ANIMATED_SIZE)
return false;
// Create QByteArray from raw data (no copy, just wraps pointer)
QByteArray imageData = QByteArray::fromRawData(reinterpret_cast<const char*>(data), size);
QBuffer buffer;
buffer.setData(imageData);
buffer.open(QIODevice::ReadOnly);
QImageReader reader(&buffer);
QString detectedFormat = QString(reader.format()).toUpper();
// Check if format is GIF or WEBP
if (detectedFormat != "GIF" && detectedFormat != "WEBP")
return false;
// Check frame count - more than 1 frame means animation
int frameCount = reader.imageCount();
bool isAnimated = (frameCount > 1);
if (isAnimated && format)
*format = detectedFormat;
return isAnimated;
}
QMovie* BoardPostImageHelper::createMovieFromData(const uint8_t* data, uint32_t size)
{
if (!data || size == 0)
return nullptr;
// Create persistent QByteArray (QMovie needs data to persist)
QByteArray* imageData = new QByteArray(reinterpret_cast<const char*>(data), size);
QBuffer* buffer = new QBuffer(imageData);
buffer->open(QIODevice::ReadOnly);
QMovie* movie = new QMovie();
movie->setDevice(buffer);
// Set buffer and data as children of movie for automatic cleanup
buffer->setParent(movie);
// Verify movie is valid
if (!movie->isValid()) {
delete movie;
delete imageData;
return nullptr;
}
return movie;
}

View File

@ -0,0 +1,58 @@
/*******************************************************************************
* retroshare-gui/src/gui/Posted/BoardPostImageHelper.h *
* *
* Copyright (C) 2025 RetroShare Team <retroshare.project@gmail.com> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU Affero General Public License as *
* published by the Free Software Foundation, either version 3 of the *
* License, or (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU Affero General Public License for more details. *
* *
* You should have received a copy of the GNU Affero General Public License *
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
#pragma once
#include <QMovie>
#include <QPixmap>
#include <QString>
#include <cstdint>
/**
* Helper class for loading and detecting animated images in Board posts.
* Supports animated GIF and WEBP formats with size limits.
*/
class BoardPostImageHelper
{
public:
// Maximum allowed size for animated images (194KB as per issue #3095)
static const uint32_t MAX_ANIMATED_SIZE = 194 * 1024;
/**
* Detect if image data contains an animated GIF or WEBP.
* @param data Raw image data
* @param size Size of image data in bytes
* @param format Optional output parameter for detected format (GIF/WEBP)
* @return true if image is animated and within size limit
*/
static bool isAnimatedImage(const uint8_t* data, uint32_t size, QString* format = nullptr);
/**
* Create QMovie object from raw image data for animation playback.
* Caller is responsible for memory management (delete when done).
* @param data Raw image data
* @param size Size of image data in bytes
* @return QMovie pointer or nullptr on failure
*/
static QMovie* createMovieFromData(const uint8_t* data, uint32_t size);
private:
BoardPostImageHelper() = delete; // Utility class, no instantiation
};

View File

@ -46,6 +46,10 @@ PhotoView::PhotoView(QWidget *parent)
/** Destructor */
PhotoView::~PhotoView()
{
if (mMovie) {
mMovie->stop();
delete mMovie;
}
delete ui;
}
@ -55,6 +59,26 @@ void PhotoView::setPixmap(const QPixmap& pixmap)
this->adjustSize();
}
void PhotoView::setMovie(QMovie* movie)
{
// Clean up previous movie if exists
if (mMovie) {
mMovie->stop();
delete mMovie;
}
mMovie = movie;
if (mMovie) {
ui->photoLabel->setMovie(mMovie);
mMovie->start();
// Loop animation when finished
connect(mMovie, &QMovie::finished, mMovie, &QMovie::start);
}
this->adjustSize();
}
void PhotoView::setTitle(const QString& text)
{
ui->titleLabel->setText(text);

View File

@ -43,6 +43,7 @@ public:
public slots:
void setPixmap(const QPixmap& pixmap);
void setMovie(QMovie* movie); // New: support for animated images
void setTitle (const QString &text);
void setName(const RsGxsId& authorID);
void setTime(const QString& text);
@ -56,6 +57,7 @@ private slots:
private:
RsGxsMessageId mMessageId;
RsGxsGroupId mGroupId;
QMovie* mMovie = nullptr; // Track QMovie for cleanup
/** Qt Designer generated object */
Ui::PhotoView *ui;

View File

@ -25,6 +25,7 @@
#include "rshare.h"
#include "PostedCardView.h"
#include "BoardPostImageHelper.h"
#include "gui/feeds/FeedHolder.h"
#include "gui/gxs/GxsIdDetails.h"
#include "util/misc.h"
@ -253,16 +254,32 @@ void PostedCardView::fill()
if(mPost.mImage.mData != NULL)
{
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
// Wiping data - as its been passed to thumbnail.
if(pixmap.width() > 800){
QPixmap scaledpixmap = pixmap.scaledToWidth(800, Qt::SmoothTransformation);
ui->pictureLabel->setPixmap(scaledpixmap);
}else{
ui->pictureLabel->setPixmap(pixmap);
}
QString format;
if (BoardPostImageHelper::isAnimatedImage(mPost.mImage.mData, mPost.mImage.mSize, &format))
{
// Animated GIF/WEBP - use QMovie
QMovie* movie = BoardPostImageHelper::createMovieFromData(mPost.mImage.mData, mPost.mImage.mSize);
if (movie)
{
ui->pictureLabel->setMovie(movie);
movie->start();
// Loop animation when finished
connect(movie, &QMovie::finished, movie, &QMovie::start);
}
}
else
{
// Static image - use QPixmap with scaling
QPixmap pixmap;
GxsIdDetails::loadPixmapFromData(mPost.mImage.mData, mPost.mImage.mSize, pixmap,GxsIdDetails::ORIGINAL);
if(pixmap.width() > 800){
QPixmap scaledpixmap = pixmap.scaledToWidth(800, Qt::SmoothTransformation);
ui->pictureLabel->setPixmap(scaledpixmap);
}else{
ui->pictureLabel->setPixmap(pixmap);
}
}
}
else //if (mPost.mImage.mData == NULL)
{

View File

@ -24,6 +24,8 @@
#include <QByteArray>
#include <QStringList>
#include <QSignalMapper>
#include <QImageReader>
#include <QFileInfo>
#include "PostedCreatePostDialog.h"
#include "ui_PostedCreatePostDialog.h"
@ -220,6 +222,24 @@ void PostedCreatePostDialog::addPicture()
// select a picture file
if (misc::getOpenFileName(window(), RshareSettings::LASTDIR_IMAGES, tr("Load Picture File"), "Pictures (*.png *.xpm *.jpg *.jpeg *.gif *.webp )", imagefilename)) {
// Check file size for animated images BEFORE loading
QFileInfo fileInfo(imagefilename);
QImageReader reader(imagefilename);
QString format = reader.format().toUpper();
int frameCount = reader.imageCount();
// Validate animated GIF/WEBP size (194KB limit)
if ((format == "GIF" || format == "WEBP") && frameCount > 1) {
if (fileInfo.size() > 194 * 1024) {
QMessageBox::warning(this, tr("Image Too Large"),
tr("Animated images must be under 194KB. This image is %1KB.\n\n"
"Please use a smaller animated image or a static image instead.")
.arg(fileInfo.size() / 1024));
imagefilename = "";
return;
}
}
QImage image;
if (image.load(imagefilename) == false) {
fprintf (stderr, "RsHtml::makeEmbeddedImage() - image \"%s\" can't be load\n", imagefilename.toLatin1().constData());

View File

@ -1376,6 +1376,7 @@ posted {
gui/Posted/PostedListWidgetWithModel.h \
gui/Posted/PostedPostsModel.h \
gui/Posted/BoardPostDisplayWidget.h \
gui/Posted/BoardPostImageHelper.h \
gui/Posted/PostedItem.h \
gui/Posted/PostedCardView.h \
gui/Posted/PostedGroupDialog.h \
@ -1402,6 +1403,7 @@ posted {
SOURCES += gui/Posted/PostedDialog.cpp \
gui/Posted/PostedListWidgetWithModel.cpp \
gui/Posted/BoardPostDisplayWidget.cpp \
gui/Posted/BoardPostImageHelper.cpp \
gui/Posted/PostedPostsModel.cpp \
gui/feeds/PostedGroupItem.cpp \
gui/Posted/PostedItem.cpp \