From 0d0faecb1c1337a29ea28433e9e7bf219d5a2b2b Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 24 Oct 2026 23:28:17 +0200 Subject: [PATCH] feat: initial video wallpaper implementation --- Helpers/Wallpapers.qml | 27 +- Modules/Launcher/Items/WallpaperTile.qml | 64 +- Modules/Settings/Common/WallItem.qml | 30 +- Modules/Settings/Common/WallpaperCropper.qml | 44 +- .../Pages/Wallpaper/WallpaperSelect.qml | 3 +- Plugins/CMakeLists.txt | 1 + Plugins/ZShell/Internal/CMakeLists.txt | 1 + Plugins/ZShell/Internal/wallpaperservice.cpp | 694 ++++++++++++++++-- Plugins/ZShell/Internal/wallpaperservice.hpp | 64 +- Plugins/ZShell/Internal/wallpapersurface.cpp | 47 +- Plugins/ZShell/Internal/wallpapersurface.hpp | 17 +- Plugins/ZShell/Models/filesystemmodel.cpp | 239 +++++- Plugins/ZShell/Models/filesystemmodel.hpp | 12 +- cli/src/zshell/subcommands/scheme.py | 125 ++++ 14 files changed, 1280 insertions(+), 88 deletions(-) diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index c335feb..bb59c3f 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -18,6 +18,8 @@ Searcher { property string actualCurrent: WallpaperPath.currentWallpaperPath property alias crops: adapter.monitorCrops readonly property string current: showPreview ? previewPath : actualCurrent + readonly property string currentThumbnail: thumbnailFor(current) + readonly property bool currentIsVideo: isVideoPath(current) property alias monitorCrops: monitorCrops property bool pendingPreviewClear property bool previewColorLock @@ -32,6 +34,29 @@ Searcher { return category; } + readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"] + + function isVideoPath(path: string): bool { + if (!path) + return false; + const dot = path.lastIndexOf("."); + if (dot < 0) + return false; + return videoExts.includes(path.slice(dot + 1).toLowerCase()); + } + + function thumbnailFor(path: string): string { + if (!isVideoPath(path)) + return path; + + for (const entry of root.list) { + if (entry.path === path && entry.thumbnailPath) + return entry.thumbnailPath; + } + + return path; + } + function getCrop(screen: string): var { return root.crops[screen]; } @@ -125,7 +150,7 @@ Searcher { FileSystemModel { id: wallpapers - filter: FileSystemModel.Images + filter: FileSystemModel.Media path: Config.general.wallpaperPath recursive: true } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 1162fee..e2d1473 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import Quickshell import QtQuick import ZShell.Config @@ -9,7 +11,7 @@ Item { id: root required property bool isCurrent - required property var modelData // FileSystemEntry-like {path, relativePath} + required property var modelData required property real tileHeight required property real tileWidth required property ScreenState screenState @@ -29,25 +31,75 @@ Item { radius: Tokens.rounding.small y: Tokens.padding.large - MaterialIcon { + Loader { + active: opacity > 0 anchors.centerIn: parent - color: Colors.tPalette.m3outline - fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() - text: "image" + opacity: img.status === Image.Ready ? 0 : 1 + + Behavior on opacity { + Anim {} + } + sourceComponent: CustomRect { + color: Colors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + radius: Tokens.rounding.full + + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(image.width, image.height) * 0.3 + } + } } CachingImage { + id: img anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top cache: true fillMode: Image.PreserveAspectCrop height: root.tileHeight - path: root.modelData ? root.modelData.path : "" + path: root.modelData ? root.modelData.thumbnailPath : "" smooth: root.isCurrent sourceSize.height: root.tileHeight sourceSize.width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9) + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } StateLayer { diff --git a/Modules/Settings/Common/WallItem.qml b/Modules/Settings/Common/WallItem.qml index 84d15e5..c4f9c57 100644 --- a/Modules/Settings/Common/WallItem.qml +++ b/Modules/Settings/Common/WallItem.qml @@ -39,8 +39,7 @@ Item { opacity: img.status === Image.Ready ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primaryContainer @@ -64,18 +63,43 @@ Item { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectCrop - opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true sourceSize: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return Qt.size(width * dpr, height * dpr); } + opacity: status === Image.Ready ? 1 : 0 Behavior on opacity { Anim { type: Anim.SlowEffects } } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 966f6ee..0c10a1b 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,7 +13,9 @@ import qs.Services Item { id: wrapper - property int buttonRowHeight: Tokens.padding.large * 3 + readonly property bool isVideo: Wallpapers.currentIsVideo + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 readonly property bool changesMade: { cropRevision; @@ -25,9 +27,9 @@ Item { return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; } + + property int buttonRowHeight: Tokens.padding.large * 3 property int cropRevision: 0 - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null - readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -127,7 +129,7 @@ Item { anchors.rightMargin: Tokens.padding.extraLarge * 2 anchors.top: parent.top - active: wrapper.screens.length > 1 + active: wrapper.screens.length > 1 && !wrapper.isVideo sourceComponent: ButtonRow { id: screenSelector @@ -163,6 +165,7 @@ Item { anchors.left: parent.left anchors.right: parent.right implicitHeight: 30 + enabled: !wrapper.isVideo CustomSlider { id: zoomSlider @@ -274,7 +277,7 @@ Item { opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true smooth: true - source: Wallpapers.current + source: Wallpapers.currentThumbnail sourceSize.height: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return imageSlot.height * dpr; @@ -285,12 +288,37 @@ Item { type: Anim.SlowEffects } } + + Loader { + opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } Loader { id: cropRectLoader - active: scaledImg.status === Image.Ready + active: scaledImg.status === Image.Ready && !wrapper.isVideo sourceComponent: Component { CustomRect { @@ -333,7 +361,6 @@ Item { y = t.y; } - // Where the rect should be according to saved data (same rules as a restore). function savedTarget() { const data = Wallpapers.getCrop(wrapper.currentScreen.name); const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); @@ -436,9 +463,10 @@ Item { } anchors.fill: parent - cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor hoverEnabled: true preventStealing: true + enabled: !wrapper.isVideo onPositionChanged: mouse => { if (pressed) { diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index a4d4906..338e9ac 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -50,11 +50,12 @@ PageBase { } WallItem { + id: wallItem required property FileSystemEntry modelData enabled: modelData opacity: modelData ? 1 : 0 - source: String(modelData?.path ?? "") + source: String(modelData?.thumbnailPath ?? "") onClicked: { Wallpapers.setWallpaper(modelData.path); diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 43f93e5..ba4b110 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -20,6 +20,7 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) +pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index b2c039a..76ad6a1 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -39,4 +39,5 @@ qml_module(ZShell-internal PkgConfig::EGL PkgConfig::WAYLAND_EGL PkgConfig::GL + PkgConfig::MPV ) diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index f8c9c10..9875d4d 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -7,15 +7,26 @@ #include #include +#include +#include + +#include + #include #include #include #include +#include #include #include #include +#include +#include +#include +#include + #include #include @@ -29,7 +40,6 @@ namespace { const char* kVertexShader = R"( #version 300 es void main() { - // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); } @@ -41,16 +51,36 @@ precision highp float; uniform sampler2D source; uniform vec2 size; uniform vec4 crop; +uniform float alpha; out vec4 outColor; void main() { - // The pixels are stored top-down, so the fragment coordinate is flipped. vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; - outColor = texture(source, crop.xy + p * crop.zw); + vec4 c = texture(source, crop.xy + p * crop.zw); + outColor = vec4(c.rgb, c.a * alpha); } )"; constexpr int kWaitTimeoutMs = 20; +constexpr int kFadeDurationMs = 400; + +const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + void logShader(uint32_t object, bool program) { int ok = 0; if (program) @@ -94,6 +124,16 @@ WallpaperService::~WallpaperService() { if (m_thread.joinable()) m_thread.join(); + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + for (auto* surface : m_surfaces) delete surface; m_surfaces.clear(); @@ -102,6 +142,8 @@ WallpaperService::~WallpaperService() { if (eglMakeCurrent( m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { if (m_texture) glDeleteTextures(1, &m_texture); + if (m_previousTexture) glDeleteTextures(1, &m_previousTexture); + if (m_blackTex) glDeleteTextures(1, &m_blackTex); if (m_program) glDeleteProgram(m_program); } @@ -127,22 +169,210 @@ QString WallpaperService::path() const { } void WallpaperService::setPath(const QString& path) { - if (m_path == path) return; + if (path.isEmpty() || path == this->path()) return; + + if (isVideoPath(path) && needsTranscode(path)) { + const QString dst = transcodeTarget(path); + + if (QFile::exists(dst)) { + applyPath(dst); + return; + } + + applyPath(path); + transcodeVideo(path, dst); + return; + } + + applyPath(path); +} + +void WallpaperService::applyPath(const QString& path) { + const bool video = isVideoPath(path); { std::lock_guard lock(m_mutex); m_path = path; + const bool wasVideo = m_isVideo && m_mpv != nullptr; + const bool wasImage = !m_image.isNull() && m_texture != 0; + + m_previousVideo = wasVideo && !video; + + if (wasImage) { + m_previousImage = m_image; + m_previousCrops = m_crops; + m_previousTexture = 0; + } else { + m_previousImage = QImage(); + } + + m_isVideo = video; + + m_fading = true; + m_fadeClockStarted = false; + m_forceDecode = true; m_imageChanged = true; + m_videoReload = true; } emit pathChanged(); start(); + queueAll(); } +bool WallpaperService::isHardwareCodec(const QString& codec) { + static const QStringList hardware{ + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", + }; + + return hardware.contains(codec); +} + +QString WallpaperService::codecName(const QString& path) const { + QProcess probe; + probe.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + + if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { + qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit" + << probe.exitCode() << ")"; + return {}; + } + + const QString codec = + QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); + qWarning() << "WallpaperService: codec of" << path << "is" + << (codec.isEmpty() ? "" : codec); + return codec; +} + +QString WallpaperService::cacheDir() const { + return QDir::homePath() + "/.cache/zshell/wallpaper-videos"; +} + +QString WallpaperService::transcodeTarget(const QString& path) const { + const QFileInfo info(path); + + const QByteArray key = + path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch()); + + const QString hash = QString::fromUtf8( + QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex()); + + return cacheDir() + '/' + hash + ".av1.mkv"; +} + +bool WallpaperService::needsTranscode(const QString& path) const { + if (path.startsWith(cacheDir())) return false; + + const QString codec = codecName(path); + if (codec.isEmpty()) return false; + + return !isHardwareCodec(codec); +} + +void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { + QDir().mkpath(cacheDir()); + + if (m_transcode) { + m_transcode->kill(); + m_transcode->deleteLater(); + m_transcode = nullptr; + } + + m_transcode = new QProcess(this); + auto* proc = m_transcode; + + connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { + qWarning().noquote() + << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); + }); + + connect( + proc, + &QProcess::finished, + this, + [this, proc, dst](int code, QProcess::ExitStatus status) { + const bool ok = code == 0 && status == QProcess::NormalExit && + QFile::exists(dst); + + qWarning() << "WallpaperService: transcode finished, exit" << code + << "status" << int(status) << "exists" + << QFile::exists(dst) << "->" << dst; + + if (proc == m_transcode) m_transcode = nullptr; + proc->deleteLater(); + + if (ok) applyPath(dst); + }); + + qWarning() << "WallpaperService: transcoding" << src << "->" << dst; + + proc->setProgram("nice"); + proc->setArguments({ + "-n", + "19", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-threads", + "2", + "-i", + src, + "-map", + "0:v:0", + "-vf", + "scale=in_range=pc:out_range=tv:out_color_matrix=bt709", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-c:v", + "libsvtav1", + "-preset", + "6", + "-crf", + "20", + "-pix_fmt", + "yuv420p10le", + "-an", + dst, + }); + + proc->start(); +} + void WallpaperService::addScreen(const QString& screen) { { std::lock_guard lock(m_mutex); @@ -212,6 +442,178 @@ void WallpaperService::redraw() { queueAll(); } +bool WallpaperService::isVideoPath(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +void WallpaperService::startVideo(const QString& path) { + if (path.isEmpty()) return; + + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); + + if (!m_mpv) { + std::setlocale(LC_NUMERIC, "C"); + + m_mpv = mpv_create(); + if (!m_mpv) { + qWarning() << "WallpaperService: could not create mpv handle"; + return; + } + + mpv_set_option_string(m_mpv, "config", "no"); + mpv_set_option_string(m_mpv, "vo", "libmpv"); + mpv_set_option_string(m_mpv, "audio", "no"); + mpv_set_option_string(m_mpv, "loop", "inf"); + mpv_set_option_string(m_mpv, "hwdec", "auto-copy"); + mpv_set_option_string(m_mpv, "video-sync", "display"); + mpv_set_option_string(m_mpv, "framedrop", "decoder"); + mpv_set_option_string(m_mpv, "background-color", "#00000000"); + + if (mpv_initialize(m_mpv) < 0) { + qWarning() << "WallpaperService: mpv_initialize failed"; + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + return; + } + } + + const QByteArray local = path.toUtf8(); + + initMpvRender(); + + const char* loadCmd[]{"loadfile", local.constData(), nullptr}; + if (mpv_command(m_mpv, loadCmd) < 0) + qWarning() << "WallpaperService: mpv could not load" << path; + + m_mpvFrameReady.store(true, std::memory_order_relaxed); +} + +void WallpaperService::pumpMpvEvents() { + if (!m_mpv) return; + + mpv_event* event = nullptr; + while ((event = mpv_wait_event(m_mpv, 0)) != nullptr && + event->event_id != MPV_EVENT_NONE) { + if (event->event_id == MPV_EVENT_LOG_MESSAGE) { + auto* data = static_cast(event->data); + if (data && data->text) + qWarning().noquote() << "mpv:" << data->prefix << data->text; + } + + if (event->event_id == MPV_EVENT_FILE_LOADED) { + m_videoLoaded.store(true, std::memory_order_relaxed); + } + + if (event->event_id == MPV_EVENT_END_FILE) { + m_videoLoaded.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + + auto* data = static_cast(event->data); + if (data && data->error != MPV_END_FILE_REASON_EOF) + qWarning() << "WallpaperService: mpv ended the file, error" + << data->error; + } + } +} + +void WallpaperService::stopVideo() { + teardownMpv(); +} + +void WallpaperService::teardownMpv() { + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + + m_mpvFrameReady.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); +} + +void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) { + (void)ctx; + + if (strcmp(name, "glFenceSync") == 0) + return reinterpret_cast( + +[](GLenum, GLbitfield) -> GLsync { return nullptr; }); + + return reinterpret_cast(eglGetProcAddress(name)); +} + +void WallpaperService::mpvUpdate(void* ctx) { + auto* self = static_cast(ctx); + + self->m_mpvFrameReady.store(true, std::memory_order_relaxed); + + if (self->m_videoLoaded.load(std::memory_order_relaxed)) + self->m_videoReady.store(true, std::memory_order_relaxed); + + self->wake(); +} + +void WallpaperService::initMpvRender() { + if (m_mpvRender || !m_mpv) return; + + mpv_opengl_init_params gl_init{getMpvProcAddress, nullptr}; + + mpv_render_param params[]{ + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) { + qWarning() << "WallpaperService: could not create mpv render context"; + m_mpvRender = nullptr; + return; + } + + mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this); +} + +void WallpaperService::renderMpvToSurface(const QSize& size) { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + glViewport(0, 0, size.width(), size.height()); + + mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA}; + + int flip = 1; + int block = 0; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(m_mpvRender, params); +} + +void WallpaperService::createBlackTexture(uint32_t& texture) { + if (texture != 0) return; + + const unsigned char pixel[4] = {0, 0, 0, 255}; + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel); +} + void WallpaperService::start() { { std::lock_guard lock(m_mutex); @@ -260,11 +662,7 @@ void WallpaperService::wake() { } void WallpaperService::queue(WallpaperSurface* surface) { - { - std::lock_guard lock(m_mutex); - m_pending.push_back(surface); - } - + surface->markDirty(); wake(); } @@ -273,7 +671,7 @@ void WallpaperService::queueAll() { std::lock_guard lock(m_mutex); for (auto* surface : m_surfaces) - m_pending.push_back(surface); + surface->markDirty(); } wake(); @@ -323,13 +721,50 @@ void WallpaperService::present() { ensureDecodeSize(); - std::vector pending; + QList surfaces; + bool fading = false; + bool videoReload = false; + QString path; + bool isVideo = false; + { std::lock_guard lock(m_mutex); - pending.swap(m_pending); + + if (m_videoReload) { + m_videoReload = false; + videoReload = true; + path = m_path; + isVideo = m_isVideo; + } + + fading = m_fading; + const bool newVideoFrame = + m_isVideo && + m_mpvFrameReady.exchange(false, std::memory_order_relaxed); + + if (fading || newVideoFrame) + for (auto* surface : m_surfaces) + surface->markDirty(); + + surfaces = m_surfaces; } - for (auto* surface : pending) + if (m_context == EGL_NO_CONTEXT) return; + + eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context); + + if (videoReload) { + if (isVideo) + startVideo(path); + else if (m_mpv && !m_previousVideo) + teardownMpv(); + } + + const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr; + + if (videoActive) pumpMpvEvents(); + + for (auto* surface : surfaces) drawSurface(surface); } @@ -494,7 +929,8 @@ uint32_t WallpaperService::createProgram() { logShader(program, true); if (glGetUniformLocation(program, "size") < 0 || - glGetUniformLocation(program, "crop") < 0) { + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0) { qWarning() << "WallpaperService: the program does not have the " "uniforms it needs"; glDeleteProgram(program); @@ -503,22 +939,23 @@ uint32_t WallpaperService::createProgram() { m_sizeUniform = glGetUniformLocation(program, "size"); m_cropUniform = glGetUniformLocation(program, "crop"); + m_alphaUniform = glGetUniformLocation(program, "alpha"); glDeleteShader(vertex); glDeleteShader(fragment); return program; } -void WallpaperService::uploadImage() { - if (m_image.isNull()) return; +void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { + if (image.isNull()) return; - if (m_texture == 0) { - glGenTextures(1, &m_texture); - glBindTexture(GL_TEXTURE_2D, m_texture); + if (texture == 0) { + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); } else { - glBindTexture(GL_TEXTURE_2D, m_texture); + glBindTexture(GL_TEXTURE_2D, texture); } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); @@ -526,12 +963,35 @@ void WallpaperService::uploadImage() { GL_TEXTURE_2D, 0, GL_RGBA8, - m_image.width(), - m_image.height(), + image.width(), + image.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, - m_image.constBits()); + image.constBits()); +} + +void WallpaperService::uploadImage() { + uploadTexture(m_texture, m_image); +} + +void WallpaperService::drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha) { + glBindTexture(GL_TEXTURE_2D, texture); + glUniform2f(GLint(m_sizeUniform), sizeW, sizeH); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_alphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); } bool WallpaperService::decode(const QString& path) { @@ -564,6 +1024,8 @@ bool WallpaperService::ensureDecodeSize() { { std::lock_guard lock(m_mutex); + if (m_isVideo) return false; + largest = largestBufferSize(); if (largest.isEmpty()) return false; @@ -611,7 +1073,10 @@ QSize WallpaperService::largestBufferSize() { } QRectF WallpaperService::sourceRect( - const QVariantMap& crops, const QString& screen, const QSize& size) const { + const QVariantMap& crops, + const QString& screen, + const QSize& size, + const QSize& image) const { QVariantMap crop = cropFor(crops, screen); QRectF rect( @@ -620,10 +1085,10 @@ QRectF WallpaperService::sourceRect( crop.value("width", 1.0).toDouble(), crop.value("height", 1.0).toDouble()); - if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; + if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect; - qreal imageWidth = qreal(m_image.width()); - qreal imageHeight = qreal(m_image.height()); + qreal imageWidth = qreal(image.width()); + qreal imageHeight = qreal(image.height()); QRectF pixels( rect.x() * imageWidth, @@ -652,6 +1117,8 @@ QRectF WallpaperService::sourceRect( } void WallpaperService::drawSurface(WallpaperSurface* surface) { + if (!surface->needsDraw() || !surface->frameReady()) return; + EGLSurface target = surface->eglSurface(); if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || surface->bufferSize().isEmpty()) @@ -662,9 +1129,8 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { return; } - // Release the context when this scope ends (see FrameService::drawSurface). - // Holding it across the draw loop's sleep serializes the two raw-EGL clients on - // NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts. + eglSwapInterval(m_eglDisplay, 0); + struct ContextRelease { EGLDisplay display; ~ContextRelease() { @@ -675,7 +1141,6 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { QVariantMap crops; QSize size; - bool upload = false; { std::lock_guard lock(m_mutex); @@ -683,38 +1148,153 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { crops = m_crops; size = surface->bufferSize(); - upload = m_imageChanged; - m_imageChanged = false; + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_program == 0) return; + + const bool videoNow = m_isVideo; + + if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; + + const bool videoAsBackground = + (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr; + + if (videoAsBackground) { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + glDisable(GL_BLEND); + + glViewport(0, 0, size.width(), size.height()); + renderMpvToSurface(size); + } else { + glUseProgram(m_program); + glViewport(0, 0, size.width(), size.height()); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + } + + if (!videoNow) { + const bool upload = m_imageChanged; + m_imageChanged = false; + + if (m_texture == 0 || upload) uploadImage(); + + if (m_texture == 0 || m_image.isNull()) return; + } + + glUseProgram(m_program); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + if (m_fading && !m_fadeClockStarted) { + m_fadeClockStarted = true; + m_fadeTimer.start(); + } + + bool fade = false; + bool fadeDone = false; + qreal progress = 1.0; + + if (m_fading) { + progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs); + if (progress >= 1.0) + fadeDone = true; + else + fade = true; + } + + const float w = float(size.width()); + const float h = float(size.height()); + + if (videoNow) { + if (fade && !m_previousImage.isNull()) { + if (m_previousTexture == 0) + uploadTexture(m_previousTexture, m_previousImage); + + if (m_previousTexture != 0) { + const QRectF previousRect = sourceRect( + m_previousCrops, + surface->screen(), + size, + m_previousImage.size()); + + drawTexture( + m_previousTexture, + previousRect, + w, + h, + 1.0f - float(progress)); + } + } else if (fade && !m_firstFadeDone) { + if (m_blackTex == 0) createBlackTexture(m_blackTex); + + if (m_blackTex != 0) + drawTexture( + m_blackTex, + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + 1.0f - float(progress)); + } + } else { + const QRectF rect = + sourceRect(crops, surface->screen(), size, m_image.size()); + + if (fade) { + if (!m_previousVideo && !m_previousImage.isNull() && + m_previousTexture == 0) { + uploadTexture(m_previousTexture, m_previousImage); + } + + if (!m_previousVideo && m_previousTexture != 0) { + const QRectF previousRect = sourceRect( + m_previousCrops, + surface->screen(), + size, + m_previousImage.size()); + + drawTexture(m_previousTexture, previousRect, w, h, 1.0f); + } + + drawTexture(m_texture, rect, w, h, float(progress)); + } else { + drawTexture(m_texture, rect, w, h, 1.0f); + } + } + + if (fadeDone) { + m_fading = false; + m_firstFadeDone = true; + + if (m_previousVideo) { + teardownMpv(); + m_previousVideo = false; + } + + if (m_previousTexture != 0) { + glDeleteTextures(1, &m_previousTexture); + m_previousTexture = 0; + } + + m_previousImage = QImage(); + } } - if (size.isEmpty()) return; + surface->requestFrame(); - if (m_program == 0) m_program = createProgram(); - if (m_texture == 0 || upload) uploadImage(); - - if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; - - glUseProgram(m_program); - glBindTexture(GL_TEXTURE_2D, m_texture); - - glUniform2f( - GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); - - QRectF rect = sourceRect(crops, surface->screen(), size); - glUniform4f( - GLint(m_cropUniform), - GLfloat(rect.x()), - GLfloat(rect.y()), - GLfloat(rect.width()), - GLfloat(rect.height())); - - glViewport(0, 0, size.width(), size.height()); - glDrawArrays(GL_TRIANGLES, 0, 3); - - if (!eglSwapBuffers(m_eglDisplay, target)) + if (!eglSwapBuffers(m_eglDisplay, target)) { qWarning() << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" << QString::number(unsigned(eglGetError()), 16); + return; + } + + if ((m_isVideo || m_previousVideo) && m_mpvRender) + mpv_render_context_report_swap(m_mpvRender); + + surface->endDraw(); } void WallpaperService::addSurface(const QString& screen) { diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp index a68d84d..d37b5b7 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -16,12 +17,18 @@ #include #include #include +#include #include #include #include "wallpapersurface.hpp" +struct mpv_handle; +struct mpv_render_context; + +class QProcess; + namespace ZShell::internal { class WallpaperSurface; @@ -57,6 +64,29 @@ class WallpaperService : public QObject { private: friend class WallpaperSurface; + static bool isVideoPath(const QString& path); + + static bool isHardwareCodec(const QString& codec); + + [[nodiscard]] QString codecName(const QString& path) const; + [[nodiscard]] QString cacheDir() const; + [[nodiscard]] QString transcodeTarget(const QString& path) const; + [[nodiscard]] bool needsTranscode(const QString& path) const; + void transcodeVideo(const QString& src, const QString& dst); + + void applyPath(const QString& path); + + void startVideo(const QString& path); + void stopVideo(); + void teardownMpv(); + + void initMpvRender(); + void renderMpvToSurface(const QSize& size); + void pumpMpvEvents(); + + static void* getMpvProcAddress(void* ctx, const char* name); + static void mpvUpdate(void* ctx); + void bindGlobals(); static void registryGlobal( void* data, @@ -70,7 +100,16 @@ class WallpaperService : public QObject { [[nodiscard]] bool setupGl(); [[nodiscard]] uint32_t createProgram(); + void uploadTexture(uint32_t& texture, const QImage& image); void uploadImage(); + void createBlackTexture(uint32_t& texture); + + void drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha); bool decode(const QString& path); @@ -95,7 +134,8 @@ class WallpaperService : public QObject { [[nodiscard]] QRectF sourceRect( const QVariantMap& crops, const QString& screen, - const QSize& size) const; + const QSize& size, + const QSize& image) const; void start(); @@ -108,6 +148,18 @@ class WallpaperService : public QObject { QImage m_image; + bool m_isVideo = false; + bool m_videoReload = false; + bool m_previousVideo = false; + mpv_handle* m_mpv = nullptr; + mpv_render_context* m_mpvRender = nullptr; + uint32_t m_blackTex = 0; + std::atomic m_mpvFrameReady{false}; + std::atomic m_videoReady{false}; + std::atomic m_videoLoaded{false}; + + QProcess* m_transcode = nullptr; + QSize m_sourceSize; QSize m_decodeSize; @@ -125,12 +177,20 @@ class WallpaperService : public QObject { uint32_t m_program = 0; int m_sizeUniform = -1; int m_cropUniform = -1; + int m_alphaUniform = -1; + + QImage m_previousImage; + QVariantMap m_previousCrops; + uint32_t m_previousTexture = 0; + bool m_fading = false; + bool m_fadeClockStarted = false; + bool m_firstFadeDone = false; + QElapsedTimer m_fadeTimer; QList m_surfaces; std::thread m_thread; mutable std::recursive_mutex m_mutex; - std::vector m_pending; wl_event_queue* m_queue = nullptr; int m_wake[2] = {-1, -1}; diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp index 8fe99e6..44c4297 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -53,6 +53,11 @@ WallpaperSurface::WallpaperSurface( } WallpaperSurface::~WallpaperSurface() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + if (m_eglSurface != EGL_NO_SURFACE) eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); @@ -68,8 +73,45 @@ WallpaperSurface::~WallpaperSurface() { if (m_surface) wl_surface_destroy(m_surface); } -void WallpaperSurface::redraw() { - m_service->queue(this); +void WallpaperSurface::markDirty() { + m_dirty.store(true, std::memory_order_relaxed); +} + +bool WallpaperSurface::needsDraw() const { + return m_dirty.load(std::memory_order_relaxed); +} + +bool WallpaperSurface::frameReady() const { + return m_frameReady.load(std::memory_order_relaxed); +} + +void WallpaperSurface::requestFrame() { + if (!m_surface || m_frameCallback) return; + + m_frameCallback = wl_surface_frame(m_surface); + + static const struct wl_callback_listener listener = { + [](void* data, struct wl_callback*, uint32_t) { + static_cast(data)->onFrameDone(); + }, + }; + + wl_callback_add_listener(m_frameCallback, &listener, this); +} + +void WallpaperSurface::onFrameDone() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + + m_frameReady.store(true, std::memory_order_relaxed); + m_service->wake(); +} + +void WallpaperSurface::endDraw() { + m_dirty.store(false, std::memory_order_relaxed); + m_frameReady.store(false, std::memory_order_relaxed); } void WallpaperSurface::zwlr_layer_surface_v1_configure( @@ -141,6 +183,7 @@ void WallpaperSurface::updateBuffer() { if (QtWayland::wp_viewport::isInitialized()) set_destination(m_size.width(), m_size.height()); + requestFrame(); m_service->queue(this); } diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Internal/wallpapersurface.hpp index d534c99..bada4ee 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.hpp +++ b/Plugins/ZShell/Internal/wallpapersurface.hpp @@ -3,6 +3,8 @@ #include #include +#include + #include #include @@ -32,7 +34,14 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } - void redraw(); + void markDirty(); + [[nodiscard]] bool needsDraw() const; + + [[nodiscard]] bool frameReady() const; + + void requestFrame(); + + void endDraw(); private: void zwlr_layer_surface_v1_configure( @@ -42,6 +51,8 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, void updateBuffer(); + void onFrameDone(); + WallpaperService* m_service; QString m_screen; wl_surface* m_surface; @@ -53,6 +64,10 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, qreal m_scale = 1.0; bool configured = false; + + std::atomic m_dirty{false}; + std::atomic m_frameReady{true}; + struct wl_callback* m_frameCallback = nullptr; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp index 02f18f0..c7d7bad 100644 --- a/Plugins/ZShell/Models/filesystemmodel.cpp +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -1,17 +1,183 @@ #include "filesystemmodel.hpp" +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include +#include + 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 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 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(this); + connect( + watcher, + &QFutureWatcher::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& removedPaths, const QSet& addedPaths) { QList removedIndices; @@ -450,8 +673,12 @@ void FileSystemModel::applyChanges( // Create new entries QList 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(), diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp index 94bb29a..b55bb47 100644 --- a/Plugins/ZShell/Models/filesystemmodel.hpp +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -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& removedPaths, const QSet& addedPaths); + void startThumbnailGeneration(FileSystemEntry* entry); [[nodiscard]] bool compareEntries( const FileSystemEntry* a, const FileSystemEntry* b) const; }; diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index f0c8e72..adc36c2 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -5,6 +5,7 @@ import re import shutil import subprocess import sys +import tempfile from pathlib import Path from typing import Any @@ -31,6 +32,23 @@ from zshell.utils.schemepalettes import ( app = typer.Typer() +VIDEO_EXTENSIONS = { + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".wmv", + ".flv", + ".m4v", + ".mpg", + ".mpeg", + ".3gp", + ".ogv", + ".ts", + ".vob", +} + def _complete_scheme_name(incomplete): schemes = [ @@ -351,6 +369,109 @@ def generate( safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) return thumb_dir / f"{safe_key}_thumbnail.jpg" + def is_video(path: Path) -> bool: + return path.suffix.lower() in VIDEO_EXTENSIONS + + def video_duration(path: Path) -> float: + try: + out = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + str(path), + ], + capture_output=True, + text=True, + check=False, + ) + return float(out.stdout.strip()) + except (ValueError, OSError): + return 0.0 + + def video_frame_chroma(path: Path) -> tuple[float, float]: + """Return (mean chroma, mean luminance) of a frame. + + Mean chroma (mean of max-min across RGB channels) is ~0 for black, + white and grey frames, and rises with saturation - so it identifies a + "good", colorful frame and rejects black or fade frames. + """ + with Image.open(path) as im: + im = im.convert("RGB") + pixels = list(im.getdata()) + + chroma = 0 + luma = 0 + for r, g, b in pixels: + chroma += max(r, g, b) - min(r, g, b) + luma += r + g + b + + n = max(1, len(pixels)) + return chroma / n, luma / (3 * n) + + def generate_video_thumbnail( + video_path: Path, cache_path: Path, size=(128, 128) + ) -> None: + w, h = size + duration = video_duration(video_path) + + samples = 8 if duration <= 0 else min(10, max(4, int(duration))) + + tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-")) + try: + best: Path | None = None + best_key: tuple[float, float] = (-1.0, -1.0) + + for i in range(samples): + frac = (i + 0.5) / samples if duration > 0 else 0.0 + seek = f"{frac * duration:.3f}" if duration > 0 else "0" + frame = tmp_dir / f"frame_{i}.jpg" + + subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + str(video_path), + "-frames:v", + "1", + "-vf", + f"scale={w}:{h}", + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + str(frame), + ], + check=False, + ) + + if not frame.exists(): + continue + + key = video_frame_chroma(frame) + if key > best_key: + best_key = key + best = frame + + if best is None: + raise RuntimeError( + f"Could not extract a frame from {video_path}" + ) + + shutil.copy(best, cache_path) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + def generate_thumbnail( image_path: Path, thumb_dir: Path, size=(128, 128) ) -> Path: @@ -360,6 +481,10 @@ def generate( if cache_path.exists(): return cache_path + if is_video(image_path): + generate_video_thumbnail(image_path, cache_path, size) + return cache_path + image = Image.open(image_path) image.draft("RGB", size) image = image.convert("RGB")