Files
utun2/tools/chatgui/src/media_preview_cache.cpp
T

202 lines
11 KiB
C++

#include "media_preview_cache.h"
#include "debug_config.h"
#include <QBuffer>
#include <QCryptographicHash>
#include <QDataStream>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QRegularExpression>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QTransform>
#include <cmath>
#include <optional>
extern "C" {
#include "video/video.h"
#ifdef HAVE_FFMPEG
#include <libavformat/avformat.h>
#include <libavutil/display.h>
#endif
}
namespace MediaPreviewCache {
namespace {
constexpr qint64 MaxRecordBytes = 4 * 1024 * 1024;
constexpr char Magic[] = "UTNPREV1";
qint64 utcNow() { return QDateTime::currentMSecsSinceEpoch(); }
bool expired(qint64 created, qint64 now) { return created <= 0 || now - created > LifetimeMs; }
QString fingerprint(const QFileInfo& source, Kind kind) {
QByteArray bytes = source.canonicalFilePath().toUtf8(); bytes.append('\0');
bytes += QByteArray::number(source.size()) + ':' + QByteArray::number(source.lastModified().toMSecsSinceEpoch());
bytes += kind == Kind::Video ? ":video:1" : ":image:1";
return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex());
}
qint64 readCreated(QFile& file) {
char magic[8]; QDataStream input(&file); input.setByteOrder(QDataStream::LittleEndian); qint64 created = 0;
if (input.readRawData(magic, 8) != 8 || QByteArray(magic, 8) != QByteArray(Magic, 8)) return 0;
input >> created; return input.status() == QDataStream::Ok ? created : 0;
}
Result readRecord(const QString& path, qint64 now) {
Result result; result.file = path; result.origin = Origin::Disk;
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { result.error = file.errorString(); return result; }
result.created = readCreated(file);
if (expired(result.created, now) || file.size() <= 16 || file.size() > MaxRecordBytes) {
result.error = QStringLiteral("expired or invalid preview record"); return result;
}
QByteArray encoded = file.readAll(); QBuffer buffer(&encoded); buffer.open(QIODevice::ReadOnly);
QImageReader reader(&buffer); const QSize size = reader.size();
if (size.width() < 1 || size.height() < 1 || size.width() > MaxSide || size.height() > MaxSide) {
result.error = QStringLiteral("invalid cached preview dimensions"); return result;
}
result.image = reader.read(); if (result.image.isNull()) result.error = reader.errorString();
return result;
}
/* JPEG первого кадра не несёт display matrix контейнера: читаем её отдельно и применяем в Qt. */
int videoRotation(const QString& path) {
#ifdef HAVE_FFMPEG
AVFormatContext* format = nullptr;
if (avformat_open_input(&format, path.toUtf8().constData(), nullptr, nullptr) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "preview cannot read video orientation source=%s", qPrintable(path)); return 0;
}
int index = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); int rotation = 0;
const uint8_t* matrix = nullptr; size_t size = 0;
if (index >= 0) {
#if LIBAVFORMAT_VERSION_MAJOR >= 61
const auto* parameters = format->streams[index]->codecpar;
const auto* side = av_packet_side_data_get(parameters->coded_side_data, parameters->nb_coded_side_data, AV_PKT_DATA_DISPLAYMATRIX);
if (side) { matrix = side->data; size = side->size; }
#else
matrix = av_stream_get_side_data(format->streams[index], AV_PKT_DATA_DISPLAYMATRIX, &size);
#endif
}
if (matrix && size >= 9 * sizeof(int32_t)) {
double angle = -av_display_rotation_get(reinterpret_cast<const int32_t*>(matrix));
if (std::isfinite(angle)) rotation = (int(std::lround(angle)) % 360 + 360) % 360;
else DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "preview invalid video orientation source=%s", qPrintable(path));
}
avformat_close_input(&format); return rotation;
#else
Q_UNUSED(path); return 0;
#endif
}
QImage prepare(const QString& path, Kind kind, QString& error) {
std::optional<QTemporaryDir> temporary;
QString input = path; int rotation = 0;
if (kind == Kind::Video) {
temporary.emplace();
if (!temporary->isValid()) { error = temporary->errorString(); return {}; }
input = temporary->filePath(QStringLiteral("first-frame.jpg"));
if (video_extract_thumbnail(path.toUtf8().constData(), input.toUtf8().constData(), MaxSide) != 0) {
error = QStringLiteral("cannot decode first video frame"); return {};
}
rotation = videoRotation(path);
}
QImageReader reader(input); reader.setAutoTransform(true); QSize size = reader.size();
if (size.width() < 1 || size.height() < 1 || size.width() > 16384 || size.height() > 16384
|| qint64(size.width()) * size.height() > 64LL * 1024 * 1024) {
error = QStringLiteral("invalid image dimensions or image exceeds 64 megapixels"); return {};
}
if (size.width() > MaxSide || size.height() > MaxSide) reader.setScaledSize(size.scaled(MaxSide, MaxSide, Qt::KeepAspectRatio));
QImage image = reader.read();
if (image.isNull()) { error = reader.errorString(); return {}; }
if (rotation) image = image.transformed(QTransform().rotate(rotation));
if (image.width() > MaxSide || image.height() > MaxSide) image = image.scaled(MaxSide, MaxSide, Qt::KeepAspectRatio, Qt::SmoothTransformation);
return image;
}
}
Cache::Cache(const QString& directory) : m_directory(directory) {
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "preview cache configured directory=%s side=%d retention_ms=%lld",
qPrintable(directory), MaxSide, (long long)LifetimeMs);
}
Result Cache::load(const QString& path, Kind kind, qint64 now) {
const bool realTime = !now; if (!now) now = utcNow();
Result result; QFileInfo source(path);
if (!source.isFile()) {
result.error = QStringLiteral("preview source is not a file");
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview source unavailable path=%s", qPrintable(path)); return result;
}
const QString key = fingerprint(source, kind); result.file = QDir(m_directory).filePath(key + ".preview");
{
QMutexLocker lock(&m_mutex);
if (auto* entry = m_memory.object(key)) {
if (!expired(entry->created, now)) {
result.image = entry->image; result.created = entry->created; result.origin = Origin::Memory;
DEBUG_TRACE(DEBUG_CATEGORY_MEDIA, "preview RAM hit key=%s created=%lld", qPrintable(key), (long long)result.created); return result;
}
m_memory.remove(key);
}
if (QFile::exists(result.file)) {
Result stored = readRecord(result.file, now);
if (!stored.image.isNull()) {
m_memory.insert(key, new Entry{stored.image, stored.created}, int(stored.image.sizeInBytes()));
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "preview disk hit key=%s created=%lld", qPrintable(key), (long long)stored.created); return stored;
}
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cache entry rejected file=%s reason=%s", qPrintable(result.file), qPrintable(stored.error));
if (!QFile::remove(result.file)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cannot remove invalid entry file=%s", qPrintable(result.file));
}
}
result.image = prepare(path, kind, result.error);
if (result.image.isNull()) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview generation failed source=%s video=%d reason=%s", qPrintable(path), kind == Kind::Video, qPrintable(result.error));
return result;
}
if (fingerprint(QFileInfo(path), kind) != key) {
result.image = {}; result.retryable = true; result.error = QStringLiteral("preview source changed during preparation");
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview source changed during generation path=%s", qPrintable(path)); return result;
}
result.created = realTime ? utcNow() : now;
QByteArray encoded; QBuffer buffer(&encoded); buffer.open(QIODevice::WriteOnly);
if (!result.image.save(&buffer, result.image.hasAlphaChannel() ? "PNG" : "JPEG", 85)) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview image encoding failed source=%s; retaining RAM preview", qPrintable(path));
} else {
QByteArray record; QDataStream output(&record, QIODevice::WriteOnly); output.setByteOrder(QDataStream::LittleEndian);
output.writeRawData(Magic, 8); output << result.created; output.writeRawData(encoded.constData(), encoded.size());
QMutexLocker lock(&m_mutex); QSaveFile destination(result.file);
if (!QDir().mkpath(m_directory) || !destination.open(QIODevice::WriteOnly)
|| destination.write(record) != record.size() || !destination.commit())
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cache write failed file=%s error=%s", qPrintable(result.file), qPrintable(destination.errorString()));
}
{
QMutexLocker lock(&m_mutex);
m_memory.insert(key, new Entry{result.image, result.created}, int(result.image.sizeInBytes()));
}
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "preview generated source=%s video=%d size=%dx%d created=%lld bytes=%lld",
qPrintable(path), kind == Kind::Video, result.image.width(), result.image.height(), (long long)result.created, (long long)encoded.size());
return result;
}
Pruned Cache::prune(qint64 now) {
if (!now) now = utcNow(); Pruned result; QMutexLocker lock(&m_mutex);
for (const auto& key : m_memory.keys()) if (expired(m_memory.object(key)->created, now)) m_memory.remove(key);
if (!QDir().mkpath(m_directory)) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cache cannot create directory=%s", qPrintable(m_directory)); ++result.errors; return result;
}
static const QRegularExpression name(QStringLiteral("^[0-9a-f]{64}\\.preview$"));
for (const auto& entry : QDir(m_directory).entryInfoList({"*.preview"}, QDir::Files | QDir::NoSymLinks)) {
if (!name.match(entry.fileName()).hasMatch()) continue;
QFile file(entry.absoluteFilePath());
if (!file.open(QIODevice::ReadOnly)) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cleanup cannot read file=%s error=%s", qPrintable(file.fileName()), qPrintable(file.errorString()));
++result.errors; continue;
}
qint64 created = readCreated(file); file.close();
if (!expired(created, now)) continue;
if (!QFile::remove(entry.absoluteFilePath())) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview cleanup cannot remove file=%s", qPrintable(entry.absoluteFilePath())); ++result.errors; continue;
}
m_memory.remove(entry.completeBaseName()); ++result.removed; result.bytes += entry.size();
}
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "preview cache cleanup directory=%s removed=%d bytes=%lld errors=%d retention_ms=%lld",
qPrintable(m_directory), result.removed, (long long)result.bytes, result.errors, (long long)LifetimeMs);
return result;
}
}