feat: initial video wallpaper implementation
C++ / fmt (pull_request) Failing after 10s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 35s
Python / verify (pull_request) Failing after 1m32s
Rust / build (pull_request) Successful in 1m0s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 1m4s

This commit is contained in:
2026-10-24 23:28:17 +02:00
parent 187e97a081
commit f858fc5e9e
14 changed files with 1280 additions and 88 deletions
+233 -6
View File
@@ -1,17 +1,183 @@
#include "filesystemmodel.hpp"
#include <qcoreapplication.h>
#include <qdatetime.h>
#include <qdir.h>
#include <qdiriterator.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qfuturewatcher.h>
#include <qimage.h>
#include <qpointer.h>
#include <qprocess.h>
#include <qregularexpression.h>
#include <qtemporarydir.h>
#include <qtconcurrentrun.h>
#include <algorithm>
namespace ZShell::models {
const QStringList kVideoExtensions = {
"mp4",
"mkv",
"webm",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"mpg",
"mpeg",
"3gp",
"ogv",
"ts",
"vob",
};
bool hasVideoExtension(const QString& path) {
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
}
QString videoThumbnailPath(const QString& path) {
const QFileInfo info(path);
QString key = QStringLiteral("%1_%2_%3")
.arg(info.baseName())
.arg(info.size())
.arg(info.lastModified().toMSecsSinceEpoch() / 1000);
key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_");
return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" +
key + "_preview.jpg";
}
bool generateVideoThumbnail(const QString& src, const QString& cachePath) {
if (QFile::exists(cachePath)) {
return true;
}
const QString scale = QStringLiteral(
"scale=1920:1080:force_original_aspect_ratio=decrease,"
"scale=trunc(iw/2)*2:trunc(ih/2)*2");
auto runProcess = [](const QString& program, const QStringList& args) {
QProcess process;
process.start(program, args);
process.waitForFinished(15000);
return qMakePair(process.exitCode(), process.readAllStandardOutput());
};
double duration = 0.0;
const auto probe = runProcess(
"ffprobe",
{
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
src,
});
if (probe.first == 0) {
duration = QString::fromUtf8(probe.second).trimmed().toDouble();
}
const int samples = duration > 0 ? std::min(10, std::max(4, int(duration)))
: 8;
QTemporaryDir tmpDir;
if (!tmpDir.isValid()) {
return false;
}
int bestIndex = -1;
double bestChroma = -1.0;
double bestLuma = -1.0;
for (int i = 0; i < samples; ++i) {
const double frac = duration > 0 ? (i + 0.5) / samples : 0.0;
const QString seek = duration > 0
? QString::number(frac * duration, 'f', 3)
: QStringLiteral("0");
const QString framePath =
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i));
runProcess(
"ffmpeg",
{
"-hide_banner",
"-loglevel",
"error",
"-ss",
seek,
"-i",
src,
"-frames:v",
"1",
"-vf",
scale,
"-pix_fmt",
"yuvj420p",
"-q:v",
"2",
"-y",
framePath,
});
QImage frame(framePath);
if (frame.isNull()) {
continue;
}
frame = frame.convertToFormat(QImage::Format_RGB888);
double chroma = 0.0;
double luma = 0.0;
int count = 0;
for (int y = 0; y < frame.height(); ++y) {
const auto* line = frame.constScanLine(y);
for (int x = 0; x < frame.width(); ++x) {
const int r = line[x * 3];
const int g = line[x * 3 + 1];
const int b = line[x * 3 + 2];
chroma += std::max({r, g, b}) - std::min({r, g, b});
luma += r + g + b;
++count;
}
}
const int n = std::max(1, count);
chroma /= n;
luma /= 3.0 * n;
if (chroma > bestChroma + 0.5 ||
(chroma > bestChroma - 0.5 && luma > bestLuma)) {
bestChroma = chroma;
bestLuma = luma;
bestIndex = i;
}
}
if (bestIndex < 0) {
return false;
}
QDir().mkpath(QFileInfo(cachePath).absolutePath());
return QFile::copy(
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)),
cachePath);
}
FileSystemEntry::FileSystemEntry(
const QString& path, const QString& relativePath, QObject* parent)
: QObject(parent)
, m_fileInfo(path)
, m_path(path)
, m_relativePath(relativePath)
, m_thumbnailPath(path)
, m_isImageInitialised(false)
, m_mimeTypeInitialised(false) {}
@@ -56,6 +222,23 @@ bool FileSystemEntry::isImage() const {
return m_isImage;
}
bool FileSystemEntry::isVideo() const {
return hasVideoExtension(m_path);
}
QString FileSystemEntry::thumbnailPath() const {
return m_thumbnailPath;
}
void FileSystemEntry::setThumbnailPath(const QString& path) {
if (m_thumbnailPath == path) {
return;
}
m_thumbnailPath = path;
emit thumbnailPathChanged();
}
QString FileSystemEntry::mimeType() const {
if (!m_mimeTypeInitialised) {
const QMimeDatabase db;
@@ -318,11 +501,20 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
std::optional<QDirIterator> iter;
if (filter == Images) {
if (filter == Images || filter == Videos || filter == Media) {
QStringList extraNameFilters = nameFilters;
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
if (filter == Images || filter == Media) {
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
}
}
if (filter == Videos || filter == Media) {
for (const auto& ext : kVideoExtensions) {
extraNameFilters << "*." + ext;
}
}
QDir::Filters filters = QDir::Files;
@@ -366,6 +558,13 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
if (!reader.canRead()) {
continue;
}
} else if (filter == Media) {
if (!hasVideoExtension(path)) {
QImageReader reader(path);
if (!reader.canRead()) {
continue;
}
}
}
newPaths.insert(path);
@@ -409,6 +608,30 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) {
const QPointer<FileSystemEntry> guard(entry);
const QString source = entry->path();
const QString cachePath = videoThumbnailPath(source);
const auto future = QtConcurrent::run([source, cachePath]() {
return generateVideoThumbnail(source, cachePath);
});
const auto watcher = new QFutureWatcher<bool>(this);
connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[guard, cachePath, watcher]() {
if (guard && watcher->result()) {
guard->setThumbnailPath(cachePath);
}
watcher->deleteLater();
});
watcher->setFuture(future);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void FileSystemModel::applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) {
QList<int> removedIndices;
@@ -450,8 +673,12 @@ void FileSystemModel::applyChanges(
// Create new entries
QList<FileSystemEntry*> newEntries;
for (const auto& path : addedPaths) {
newEntries << new FileSystemEntry(
path, m_dir.relativeFilePath(path), this);
auto* entry =
new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
if (entry->isVideo()) {
startThumbnailGeneration(entry);
}
newEntries << entry;
}
std::sort(
newEntries.begin(),
+11 -1
View File
@@ -28,6 +28,9 @@ class FileSystemEntry : public QObject {
Q_PROPERTY(qint64 size READ size CONSTANT)
Q_PROPERTY(bool isDir READ isDir CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(bool isVideo READ isVideo CONSTANT)
Q_PROPERTY(
QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
public:
@@ -45,18 +48,24 @@ class FileSystemEntry : public QObject {
[[nodiscard]] qint64 size() const;
[[nodiscard]] bool isDir() const;
[[nodiscard]] bool isImage() const;
[[nodiscard]] bool isVideo() const;
[[nodiscard]] QString thumbnailPath() const;
[[nodiscard]] QString mimeType() const;
void setThumbnailPath(const QString& path);
void updateRelativePath(const QDir& dir);
signals:
void relativePathChanged();
void thumbnailPathChanged();
private:
const QFileInfo m_fileInfo;
const QString m_path;
QString m_relativePath;
QString m_thumbnailPath;
mutable bool m_isImage;
mutable bool m_isImageInitialised;
@@ -91,7 +100,7 @@ class FileSystemModel : public QAbstractListModel {
NOTIFY entriesChanged)
public:
enum Filter { NoFilter, Images, Files, Dirs };
enum Filter { NoFilter, Images, Videos, Media, Files, Dirs };
Q_ENUM(Filter)
explicit FileSystemModel(QObject* parent = nullptr);
@@ -156,6 +165,7 @@ class FileSystemModel : public QAbstractListModel {
void updateEntriesForDir(const QString& dir);
void applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
void startThumbnailGeneration(FileSystemEntry* entry);
[[nodiscard]] bool compareEntries(
const FileSystemEntry* a, const FileSystemEntry* b) const;
};