diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index bb59c3f..272e106 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -27,7 +27,7 @@ Searcher { property bool recentlyChanged property bool showPreview: false - function getCategoryFor(w: FileSystemEntry): string { + function getCategoryFor(w: WallpaperEntry): string { let category = w.parentDir.slice(Paths.wallsdir.length + 1); if (category.includes("/")) category = category.slice(0, category.indexOf("/")); @@ -46,12 +46,12 @@ Searcher { } function thumbnailFor(path: string): string { - if (!isVideoPath(path)) - return path; + if (!path) + return ""; for (const entry of root.list) { - if (entry.path === path && entry.thumbnailPath) - return entry.thumbnailPath; + if (entry.path === path) + return entry.thumbnailPath || path; } return path; @@ -147,12 +147,13 @@ Searcher { onTriggered: monitorCrops.writeAdapter() } - FileSystemModel { + WallpaperDb { id: wallpapers - filter: FileSystemModel.Media + dbPath: `${Paths.state}/wallpapers.sqlite` + cacheSize: wallpaper.largestBufferSize + requireHardwareCodec: true path: Config.general.wallpaperPath - recursive: true } Process { @@ -171,9 +172,19 @@ Searcher { WallpaperService { id: wallpaper + dbPath: wallpapers.dbPath path: root.current } + Connections { + target: wallpapers + + function onCacheStored(path: string): void { + if (path === root.current) + wallpaper.invalidateCache(); + } + } + Variants { model: Quickshell.screens diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index e2d1473..206cb16 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -55,15 +55,15 @@ Item { } } - CachingImage { + Image { id: img anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - cache: true + asynchronous: true fillMode: Image.PreserveAspectCrop height: root.tileHeight - path: root.modelData ? root.modelData.thumbnailPath : "" + source: root.modelData ? root.modelData.thumbnailPath : "" smooth: root.isCurrent sourceSize.height: root.tileHeight sourceSize.width: root.tileHeight * (16 / 9) diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 338e9ac..f0b0abd 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -51,7 +51,7 @@ PageBase { WallItem { id: wallItem - required property FileSystemEntry modelData + required property WallpaperEntry modelData enabled: modelData opacity: modelData ? 1 : 0 diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index ba4b110..4e87c55 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -21,6 +21,12 @@ 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(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) +pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) +pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) + +find_package(CUDAToolkit QUIET) 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 76ad6a1..a81311e 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -22,11 +22,13 @@ qml_module(ZShell-internal linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp + videoplayer.hpp videoplayer.cpp cursor.hpp LIBRARIES Qt::Gui Qt::Quick Qt::Concurrent + Qt::Sql Qt::Core Qt::Network Qt::DBus @@ -40,4 +42,20 @@ qml_module(ZShell-internal PkgConfig::WAYLAND_EGL PkgConfig::GL PkgConfig::MPV + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL ) + +qt_add_resources(ZShell-internal "wallpaper_shaders" + PREFIX "/" + FILES + shaders/wallpaper.vert + shaders/wallpaper.frag + shaders/wallpaper_video.frag +) + +if(CUDAToolkit_FOUND) + target_link_libraries(ZShell-internal PRIVATE CUDA::cuda_driver) + target_compile_definitions(ZShell-internal PRIVATE ZHAVE_VIDEO=1) +endif() diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.frag b/Plugins/ZShell/Internal/shaders/wallpaper.frag new file mode 100644 index 0000000..b0db80a --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper.frag @@ -0,0 +1,11 @@ +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +uniform float alpha; +out vec4 outColor; + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec4 c = texture(source, crop.xy + p * crop.zw); + outColor = vec4(c.rgb, c.a * alpha); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.vert b/Plugins/ZShell/Internal/shaders/wallpaper.vert new file mode 100644 index 0000000..8529808 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper.vert @@ -0,0 +1,4 @@ +void main() { + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_video.frag b/Plugins/ZShell/Internal/shaders/wallpaper_video.frag new file mode 100644 index 0000000..9c5aa55 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_video.frag @@ -0,0 +1,105 @@ +uniform sampler2D luma; +uniform sampler2D chroma; +uniform vec2 size; +uniform vec2 srcSize; +uniform vec4 crop; +uniform float alpha; +uniform int matrix; +uniform int range; +uniform vec2 chromaOff; +out vec4 outColor; + +float noise(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +const float PI = 3.1415926535893; + +float sinc(float x) { + x *= PI; + return abs(x) < 1e-6 ? 1.0 : sin(x) / x; +} + +float lanczos(float x) { + if (abs(x) >= 3.0) + return 0.0; + return sinc(x) * sinc(x / 3.0); +} + +float hermite(float x) { + float a = abs(x); + if (a >= 1.0) + return 0.0; + return 1.0 - 3.0 * a * a + 2.0 * a * a * a; +} + +vec4 resample(sampler2D tex, vec2 plane, vec2 dest, vec2 uv) { + vec2 pt = 1.0 / plane; + float wx[6]; + float wy[6]; + vec2 base; + + int upx = dest.x >= plane.x ? 1 : 0; + int tx = upx == 1 ? 6 : 2; + float fsx = max(1.0, plane.x / dest.x); + float f = fract(uv.x * plane.x - 0.5); + base.x = uv.x - f * pt.x - pt.x * (float(tx) * 0.5 - 1.0); + float sx = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= tx) { wx[i] = 0.0; continue; } + float x = f - (float(i) - float(tx) * 0.5 + 1.0); + wx[i] = upx == 1 ? lanczos(x / fsx) : hermite(x / fsx); + sx += wx[i]; + } + if (sx != 0.0) + for (int i = 0; i < 6; ++i) wx[i] /= sx; + + int upy = dest.y >= plane.y ? 1 : 0; + int ty = upy == 1 ? 6 : 2; + float fsy = max(1.0, plane.y / dest.y); + f = fract(uv.y * plane.y - 0.5); + base.y = uv.y - f * pt.y - pt.y * (float(ty) * 0.5 - 1.0); + float sy = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= ty) { wy[i] = 0.0; continue; } + float y = f - (float(i) - float(ty) * 0.5 + 1.0); + wy[i] = upy == 1 ? lanczos(y / fsy) : hermite(y / fsy); + sy += wy[i]; + } + if (sy != 0.0) + for (int i = 0; i < 6; ++i) wy[i] /= sy; + + vec4 col = vec4(0.0); + for (int i = 0; i < 6; ++i) { + if (wx[i] == 0.0) + continue; + for (int j = 0; j < 6; ++j) { + if (wy[j] == 0.0) + continue; + vec2 c = base + vec2(float(i) * pt.x, float(j) * pt.y); + col += (wx[i] * wy[j]) * texture(tex, c); + } + } + return col; +} + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec2 uv = crop.xy + p * crop.zw; + float Yv = resample(luma, srcSize, size, uv).r; + vec2 ch = resample(chroma, srcSize * 0.5, size, uv + chromaOff).rg; + vec3 yuv; + if (range == 0) { + yuv = vec3((Yv - 16.0 / 255.0) / (219.0 / 255.0), + (ch.r - 128.0 / 255.0) / (224.0 / 255.0), + (ch.g - 128.0 / 255.0) / (224.0 / 255.0)); + } else { + yuv = vec3(Yv, ch.r - 0.5, ch.g - 0.5); + } + mat3 m = matrix == 1 + ? mat3(1.0, 1.0, 1.0, 0.0, -0.187324, 1.855600, 1.574800, -0.468124, 0.0) + : mat3(1.0, 1.0, 1.0, 0.0, -0.344136, 1.772000, 1.402000, -0.714136, 0.0); + vec3 rgb = m * yuv; + rgb += (noise(gl_FragCoord.xy) - 0.5) / 255.0; + outColor = vec4(clamp(rgb, 0.0, 1.0), alpha); +} diff --git a/Plugins/ZShell/Internal/videoplayer.cpp b/Plugins/ZShell/Internal/videoplayer.cpp new file mode 100644 index 0000000..ba49577 --- /dev/null +++ b/Plugins/ZShell/Internal/videoplayer.cpp @@ -0,0 +1,529 @@ +#include "videoplayer.hpp" + +#ifdef ZHAVE_VIDEO + +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include +#include +#include +#include + +#include +#include +#include + +#include + +extern "C" CUresult cuGraphicsGLRegisterImage( + CUgraphicsResource* resource, + GLuint image, + GLenum target, + unsigned int flags); + +Q_LOGGING_CATEGORY(videoLog, "ZShell.Internal.wallpaper") + +namespace { + +enum AVPixelFormat chooseFormat( + AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + (void)ctx; + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + if (*p == AV_PIX_FMT_CUDA) return AV_PIX_FMT_CUDA; + return pix_fmts[0]; +} + +} // namespace + +VideoPlayer::VideoPlayer(EGLDisplay display) : m_eglDisplay(display) {} + +VideoPlayer::~VideoPlayer() { + stop(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + av_frame_free(&m_pending); + } + if (m_displayed) { + av_frame_unref(m_displayed); + av_frame_free(&m_displayed); + } + + if (m_cuCtx) { + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + cuCtxPopCurrent(&prev); + cuCtxDestroy(m_cuCtx); + m_cuCtx = nullptr; + } + m_texSize = QSize(); +} + +void VideoPlayer::setReadyCallback(std::function callback) { + m_readyCb = std::move(callback); +} + +void VideoPlayer::setFrameCallback(std::function callback) { + m_frameCb = std::move(callback); +} + +bool VideoPlayer::openDecoder(const QString& path) { + if (avformat_open_input( + &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { + qCWarning( + videoLog, + "VideoPlayer: could not open %s", + path.toUtf8().constData()); + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not read stream info"); + return false; + } + + m_streamIndex = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + m_streamIndex = int(i); + break; + } + if (m_streamIndex < 0) { + qCWarning(videoLog, "VideoPlayer: no video stream"); + return false; + } + + AVCodecParameters* par = m_fmt->streams[m_streamIndex]->codecpar; + const AVCodec* codec = avcodec_find_decoder(par->codec_id); + const AVCodec* hwcodec = nullptr; + void* it = nullptr; + while (const AVCodec* c = av_codec_iterate(&it)) { + if (c->id != par->codec_id || !av_codec_is_decoder(c)) continue; + for (int i = 0; + const AVCodecHWConfig* cfg = avcodec_get_hw_config(c, i); + ++i) + if (cfg->device_type == AV_HWDEVICE_TYPE_CUDA && + (cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) { + hwcodec = c; + break; + } + if (hwcodec) break; + } + if (hwcodec) codec = hwcodec; + if (!codec) { + qCWarning( + videoLog, + "VideoPlayer: no decoder for codec id %d", + int(par->codec_id)); + return false; + } + + m_codec = avcodec_alloc_context3(codec); + if (!m_codec) return false; + if (avcodec_parameters_to_context(m_codec, par) < 0) { + qCWarning(videoLog, "VideoPlayer: could not copy codec params"); + return false; + } + + m_hwDeviceCtx = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_CUDA); + if (!m_hwDeviceCtx) { + qCWarning( + videoLog, "VideoPlayer: could not allocate cuda hwdevice context"); + return false; + } + AVCUDADeviceContext* cudaHw = reinterpret_cast( + reinterpret_cast(m_hwDeviceCtx->data)->hwctx); + if (m_cuCtx) cudaHw->cuda_ctx = m_cuCtx; + if (av_hwdevice_ctx_init(m_hwDeviceCtx) < 0) { + qCWarning( + videoLog, "VideoPlayer: could not create cuda hwdevice context"); + av_buffer_unref(&m_hwDeviceCtx); + return false; + } + m_codec->hw_device_ctx = av_buffer_ref(m_hwDeviceCtx); + m_codec->get_format = chooseFormat; + + if (avcodec_open2(m_codec, codec, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not open codec"); + return false; + } + + return true; +} + +void VideoPlayer::closeDecoder() { + if (m_codec) { + avcodec_free_context(&m_codec); + m_codec = nullptr; + } + if (m_hwDeviceCtx) { + av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + } + if (m_fmt) { + avformat_close_input(&m_fmt); + m_fmt = nullptr; + } + m_streamIndex = -1; +} + +void VideoPlayer::decodeLoop(const QString& path) { + if (!openDecoder(path)) { + m_threadRunning = false; + return; + } + + AVRational fr = m_fmt->streams[m_streamIndex]->avg_frame_rate; + if (fr.num <= 0 || fr.den <= 0) fr = m_codec->framerate; + if (fr.num <= 0 || fr.den <= 0) fr = AVRational{30, 1}; + const int64_t intervalUs = + av_rescale_q(1, AVRational{fr.den, fr.num}, AVRational{1, 1000000}); + + AVFrame* frame = av_frame_alloc(); + if (!frame) { + m_threadRunning = false; + return; + } + + AVPacket pkt{}; + + while (m_threadRunning) { + int ret = av_read_frame(m_fmt, &pkt); + if (ret == AVERROR_EOF) { + if (avformat_seek_file( + m_fmt, m_streamIndex, INT64_MIN, 0, INT64_MAX, 0) < 0) { + qCWarning(videoLog, "VideoPlayer: loop seek failed; stopping"); + break; + } + avcodec_flush_buffers(m_codec); + continue; + } + if (ret < 0) { + qCWarning(videoLog, "VideoPlayer: read error; stopping"); + break; + } + if (pkt.stream_index != m_streamIndex) { + av_packet_unref(&pkt); + continue; + } + + if (avcodec_send_packet(m_codec, &pkt) < 0) { + av_packet_unref(&pkt); + continue; + } + av_packet_unref(&pkt); + + while (m_threadRunning && avcodec_receive_frame(m_codec, frame) == 0) { + bool wasEmpty = false; + { + std::lock_guard lock(m_mtx); + if (!m_pending) m_pending = av_frame_alloc(); + av_frame_unref(m_pending); + if (av_frame_ref(m_pending, frame) >= 0) { + wasEmpty = !m_haveFrame; + m_haveFrame = true; + } + } + if (wasEmpty && m_readyCb) m_readyCb(); + if (m_frameCb) m_frameCb(); + av_frame_unref(frame); + } + + std::this_thread::sleep_for(std::chrono::microseconds(intervalUs)); + } + + av_frame_free(&frame); + closeDecoder(); + m_threadRunning = false; +} + +bool VideoPlayer::start(const QString& path) { + stop(); + + if (!ensureCudaContext()) return false; + + m_threadRunning = true; + m_thread = std::thread([this, path]() { decodeLoop(path); }); + return true; +} + +void VideoPlayer::stop() { + m_threadRunning = false; + if (m_thread.joinable()) m_thread.join(); + + closeDecoder(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + m_pending = nullptr; + } + if (m_displayed) { + av_frame_unref(m_displayed); + m_displayed = nullptr; + } + m_haveFrame = false; + m_size = QSize(); +} + +QSize VideoPlayer::frameSize() const { + std::lock_guard lock(m_mtx); + return m_size; +} + +void VideoPlayer::deleteTexture() { + std::lock_guard lock(m_mtx); + CUcontext prev = nullptr; + if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + if (m_cuCtx) cuCtxPopCurrent(&prev); + m_texSize = QSize(); +} + +bool VideoPlayer::ensureCudaContext() { + if (m_cuCtx) return true; + + if (cuInit(0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuInit failed"); + return false; + } + CUdevice dev = 0; + if (cuDeviceGet(&dev, 0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuDeviceGet failed"); + return false; + } + if (cuCtxCreate_v4(&m_cuCtx, nullptr, 0, dev) != CUDA_SUCCESS) { + m_cuCtx = nullptr; + qCWarning(videoLog, "VideoPlayer: cuCtxCreate failed"); + return false; + } + CUcontext popped = nullptr; + cuCtxPopCurrent(&popped); + return true; +} + +bool VideoPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { + if (!ensureCudaContext()) return false; + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + + const QSize sz = QSize(int(w), int(h)); + if (m_texSize == sz && m_texComp == compBytes && m_texY != 0) { + cuCtxPopCurrent(&prev); + return true; + } + + if (m_resY) cuGraphicsUnregisterResource(m_resY); + if (m_resUV) cuGraphicsUnregisterResource(m_resUV); + m_resY = m_resUV = nullptr; + if (m_texY) glDeleteTextures(1, &m_texY); + if (m_texUV) glDeleteTextures(1, &m_texUV); + m_texY = m_texUV = 0; + + const bool wide = compBytes == 2; + auto makeTex = + [&](GLuint& tex, GLint internal, GLenum fmt, unsigned tw, unsigned th) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal, + GLint(tw), + GLint(th), + 0, + fmt, + wide ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE, + nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + makeTex(m_texY, wide ? GL_R16 : GL_R8, GL_RED, w, h); + makeTex(m_texUV, wide ? GL_RG16 : GL_RG8, GL_RG, w / 2, h / 2); + + CUresult r = cuGraphicsGLRegisterImage( + &m_resY, + m_texY, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + const char* es = nullptr; + cuGetErrorString(r, &es); + qCWarning( + videoLog, + "VideoPlayer: register luma tex failed (%s)", + es ? es : "?"); + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + r = cuGraphicsGLRegisterImage( + &m_resUV, + m_texUV, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: register chroma tex failed"); + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + + m_texSize = sz; + m_texComp = compBytes; + cuCtxPopCurrent(&prev); + return true; +} + +bool VideoPlayer::importCurrentFrame() { + AVFrame* frame = nullptr; + { + std::lock_guard lock(m_mtx); + if (!m_pending || m_pending->format == AV_PIX_FMT_NONE) return false; + frame = m_pending; + m_pending = nullptr; + } + + if (frame->format != AV_PIX_FMT_CUDA || !frame->hw_frames_ctx) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + videoLog, + "VideoPlayer: frame not on GPU (fmt=%s hw_frames=%d)", + av_get_pix_fmt_name(AVPixelFormat(frame->format)), + frame->hw_frames_ctx != nullptr); + av_frame_unref(frame); + return false; + } + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVPixelFormat sw = hf->sw_format; + const bool tenBit = sw == AV_PIX_FMT_P010LE || sw == AV_PIX_FMT_P010BE || + sw == AV_PIX_FMT_P012LE || sw == AV_PIX_FMT_P016LE; + const int compBytes = tenBit ? 2 : 1; + const size_t compSize = size_t(compBytes); + + const unsigned w = unsigned(frame->width); + const unsigned h = unsigned(frame->height); + if (w == 0 || h == 0) { + av_frame_unref(frame); + return false; + } + + size_t pitchY = size_t(frame->linesize[0]); + size_t pitchUV = frame->linesize[1] > 0 ? size_t(frame->linesize[1]) + : pitchY; + if (pitchY == 0) pitchY = size_t(w) * compSize; + + switch (frame->colorspace) { + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + m_matrix = 0; + break; + case AVCOL_SPC_BT709: + m_matrix = 1; + break; + default: + m_matrix = h >= 720 ? 1 : 0; + break; + } + m_range = frame->color_range == AVCOL_RANGE_JPEG ? 1 : 0; + m_size = QSize(int(w), int(h)); + + if (!ensureTextures(w, h, compBytes)) { + av_frame_unref(frame); + return false; + } + + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + CUgraphicsResource res[2] = {m_resY, m_resUV}; + CUresult r = cuGraphicsMapResources(2, res, nullptr); + if (r != CUDA_SUCCESS) { + cuCtxPopCurrent(&prev); + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuGraphicsMapResources failed"); + return false; + } + + CUarray arrY = nullptr, arrUV = nullptr; + cuGraphicsSubResourceGetMappedArray(&arrY, m_resY, 0, 0); + cuGraphicsSubResourceGetMappedArray(&arrUV, m_resUV, 0, 0); + + const size_t dstPitch = size_t(w) * compSize; + CUDA_MEMCPY2D mc{}; + mc.srcMemoryType = CU_MEMORYTYPE_DEVICE; + mc.dstMemoryType = CU_MEMORYTYPE_ARRAY; + mc.srcDevice = CUdeviceptr(frame->data[0]); + mc.srcPitch = pitchY; + mc.dstArray = arrY; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchY, dstPitch); + mc.Height = h; + r = cuMemcpy2D(&mc); + if (r == CUDA_SUCCESS) { + mc.srcDevice = CUdeviceptr(frame->data[1]); + mc.srcPitch = pitchUV; + mc.dstArray = arrUV; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchUV, dstPitch); + mc.Height = h / 2; + r = cuMemcpy2D(&mc); + } + + cuGraphicsUnmapResources(2, res, nullptr); + cuCtxPopCurrent(&prev); + + if (r != CUDA_SUCCESS) { + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuMemcpy2D failed"); + return false; + } + + { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + } + + return true; +} + +#endif diff --git a/Plugins/ZShell/Internal/videoplayer.hpp b/Plugins/ZShell/Internal/videoplayer.hpp new file mode 100644 index 0000000..b6137e0 --- /dev/null +++ b/Plugins/ZShell/Internal/videoplayer.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include + +#include +#include +#include +#include + +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +} + +class VideoPlayer { + public: + explicit VideoPlayer(EGLDisplay display); + ~VideoPlayer(); + + void setReadyCallback(std::function callback); + void setFrameCallback(std::function callback); + + bool start(const QString& path); + void stop(); + + + bool importCurrentFrame(); + + void deleteTexture(); + + [[nodiscard]] uint32_t textureId() const { return m_texY; } + [[nodiscard]] uint32_t chromaTextureId() const { return m_texUV; } + [[nodiscard]] int colorMatrix() const { return m_matrix; } + [[nodiscard]] int colorRange() const { return m_range; } + [[nodiscard]] QSize frameSize() const; + + private: + void decodeLoop(const QString& path); + bool openDecoder(const QString& path); + void closeDecoder(); + + bool ensureTextures(unsigned w, unsigned h, int compBytes); + + bool ensureCudaContext(); + + EGLDisplay m_eglDisplay; + std::function m_readyCb; + std::function m_frameCb; + + std::thread m_thread; + std::atomic m_threadRunning{false}; + + mutable std::mutex m_mtx; + AVFrame* m_pending = nullptr; + bool m_haveFrame = false; + + AVFrame* m_displayed = nullptr; + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_codec = nullptr; + AVBufferRef* m_hwDeviceCtx = nullptr; + int m_streamIndex = -1; + + CUcontext m_cuCtx = nullptr; + CUgraphicsResource m_resY = nullptr; + CUgraphicsResource m_resUV = nullptr; + QSize m_texSize; + + uint32_t m_texY = 0; + uint32_t m_texUV = 0; + + int m_matrix = 1; + int m_range = 0; + int m_texComp = 1; + QSize m_size; +}; diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index 9875d4d..9b512e8 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -7,6 +7,10 @@ #include #include +#ifndef GL_TEXTURE_EXTERNAL_OES +#define GL_TEXTURE_EXTERNAL_OES 0x8D66 +#endif + #include #include @@ -17,10 +21,15 @@ #include #include +#include #include #include #include #include +#include +#include +#include +#include #include #include @@ -35,30 +44,38 @@ namespace ZShell::internal { +Q_LOGGING_CATEGORY(wpLog, "ZShell.Internal.wallpaper") + namespace { -const char* kVertexShader = R"( -#version 300 es -void main() { - vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); -} -)"; +#ifdef ZHAVE_VIDEO +const bool kMpvMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_MPV"); +#endif -const char* kFragmentShader = R"( -#version 300 es -precision highp float; -uniform sampler2D source; -uniform vec2 size; -uniform vec4 crop; -uniform float alpha; -out vec4 outColor; -void main() { - vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; - vec4 c = texture(source, crop.xy + p * crop.zw); - outColor = vec4(c.rgb, c.a * alpha); +#ifdef ZHAVE_VIDEO +const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES") || + kMpvMode; +#else +const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES"); +#endif + +const char* shaderHeader(bool fragment) { + if (kGlesMode) + return fragment ? "#version 300 es\nprecision highp float;\n" + : "#version 300 es\n"; + return "#version 330\n"; +} + +QByteArray shaderSource(const QString& file, bool fragment) { + QFile source(QStringLiteral(":/shaders/") + file); + if (!source.open(QIODevice::ReadOnly)) { + qWarning(wpLog) << "WallpaperService: could not read the shader" + << file; + return {}; + } + + return QByteArray(shaderHeader(fragment)) + source.readAll(); } -)"; constexpr int kWaitTimeoutMs = 20; @@ -96,8 +113,8 @@ void logShader(uint32_t object, bool program) { else glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); - qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") - << "failed:" << info; + qWarning(wpLog) << "WallpaperService:" + << (program ? "linking" : "compiling") << "failed:" << info; } QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { @@ -143,8 +160,19 @@ WallpaperService::~WallpaperService() { m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { if (m_texture) glDeleteTextures(1, &m_texture); if (m_previousTexture) glDeleteTextures(1, &m_previousTexture); + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); if (m_blackTex) glDeleteTextures(1, &m_blackTex); if (m_program) glDeleteProgram(m_program); + if (m_videoProgram) glDeleteProgram(m_videoProgram); +#ifdef ZHAVE_VIDEO + if (m_player) { + m_player->stop(); + m_player->deleteTexture(); + m_player.reset(); + } +#endif } eglDestroyContext(m_eglDisplay, m_context); @@ -168,6 +196,23 @@ QString WallpaperService::path() const { return m_path; } +QString WallpaperService::dbPath() const { + std::lock_guard lock(m_mutex); + return m_dbPath; +} + +void WallpaperService::setDbPath(const QString& path) { + { + std::lock_guard lock(m_mutex); + if (m_dbPath == path) return; + m_dbPath = path; + m_forceDecode = true; + } + + emit dbPathChanged(); + wake(); +} + void WallpaperService::setPath(const QString& path) { if (path.isEmpty() || path == this->path()) return; @@ -194,12 +239,19 @@ void WallpaperService::applyPath(const QString& path) { std::lock_guard lock(m_mutex); m_path = path; - const bool wasVideo = m_isVideo && m_mpv != nullptr; +#ifdef ZHAVE_VIDEO + const bool videoBackend = m_mpv != nullptr || m_player != nullptr; +#else + const bool videoBackend = m_mpv != nullptr; +#endif + const bool wasVideo = m_isVideo && videoBackend; const bool wasImage = !m_image.isNull() && m_texture != 0; m_previousVideo = wasVideo && !video; if (wasImage) { + if (m_previousTexture != 0) + m_orphanTextures.push_back(m_previousTexture); m_previousImage = m_image; m_previousCrops = m_crops; m_previousTexture = 0; @@ -211,6 +263,7 @@ void WallpaperService::applyPath(const QString& path) { m_fading = true; m_fadeClockStarted = false; + m_replacementReady = false; m_forceDecode = true; m_imageChanged = true; @@ -258,15 +311,13 @@ QString WallpaperService::codecName(const QString& path) const { }); if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { - qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit" - << probe.exitCode() << ")"; + qWarning(wpLog) << "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; } @@ -309,7 +360,7 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { auto* proc = m_transcode; connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { - qWarning().noquote() + qWarning(wpLog).noquote() << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); }); @@ -321,9 +372,9 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { 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; + qWarning(wpLog) << "WallpaperService: transcode finished, exit" + << code << "status" << int(status) << "exists" + << QFile::exists(dst) << "->" << dst; if (proc == m_transcode) m_transcode = nullptr; proc->deleteLater(); @@ -331,7 +382,7 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { if (ok) applyPath(dst); }); - qWarning() << "WallpaperService: transcoding" << src << "->" << dst; + qWarning(wpLog) << "WallpaperService: transcoding" << src << "->" << dst; proc->setProgram("nice"); proc->setArguments({ @@ -452,12 +503,23 @@ void WallpaperService::startVideo(const QString& path) { m_videoReady.store(false, std::memory_order_relaxed); m_videoLoaded.store(false, std::memory_order_relaxed); +#ifdef ZHAVE_VIDEO + if (m_usePlayer && !kMpvMode) { + if (startPlayerVideo(path)) return; + + qWarning(wpLog) << "WallpaperService: the GPU video player failed, " + "falling back to mpv"; + m_usePlayer = false; + m_player.reset(); + } +#endif + if (!m_mpv) { std::setlocale(LC_NUMERIC, "C"); m_mpv = mpv_create(); if (!m_mpv) { - qWarning() << "WallpaperService: could not create mpv handle"; + qWarning(wpLog) << "WallpaperService: could not create mpv handle"; return; } @@ -471,7 +533,7 @@ void WallpaperService::startVideo(const QString& path) { mpv_set_option_string(m_mpv, "background-color", "#00000000"); if (mpv_initialize(m_mpv) < 0) { - qWarning() << "WallpaperService: mpv_initialize failed"; + qWarning(wpLog) << "WallpaperService: mpv_initialize failed"; mpv_terminate_destroy(m_mpv); m_mpv = nullptr; return; @@ -484,7 +546,7 @@ void WallpaperService::startVideo(const QString& path) { const char* loadCmd[]{"loadfile", local.constData(), nullptr}; if (mpv_command(m_mpv, loadCmd) < 0) - qWarning() << "WallpaperService: mpv could not load" << path; + qWarning(wpLog) << "WallpaperService: mpv could not load" << path; m_mpvFrameReady.store(true, std::memory_order_relaxed); } @@ -495,12 +557,6 @@ void WallpaperService::pumpMpvEvents() { 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); } @@ -511,16 +567,12 @@ void WallpaperService::pumpMpvEvents() { auto* data = static_cast(event->data); if (data && data->error != MPV_END_FILE_REASON_EOF) - qWarning() << "WallpaperService: mpv ended the file, error" - << data->error; + qWarning(wpLog) << "WallpaperService: mpv ended the file, error" + << data->error; } } } -void WallpaperService::stopVideo() { - teardownMpv(); -} - void WallpaperService::teardownMpv() { if (m_mpvRender) { mpv_render_context_free(m_mpvRender); @@ -570,8 +622,11 @@ void WallpaperService::initMpvRender() { {MPV_RENDER_PARAM_INVALID, nullptr}, }; - if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) { - qWarning() << "WallpaperService: could not create mpv render context"; + const int rc = mpv_render_context_create(&m_mpvRender, m_mpv, params); + if (rc < 0) { + qWarning(wpLog) + << "WallpaperService: could not create mpv render context, code" + << rc; m_mpvRender = nullptr; return; } @@ -626,7 +681,7 @@ void WallpaperService::start() { auto* interface = qApp->nativeInterface(); if (!interface) { - qWarning() << "WallpaperService: this is not a Wayland session"; + qWarning(wpLog) << "WallpaperService: this is not a Wayland session"; return; } @@ -638,7 +693,8 @@ void WallpaperService::start() { if (!m_queue) return; if (pipe2(m_wake, O_CLOEXEC) < 0) { - qWarning() << "WallpaperService: could not make the pipe to wake on"; + qWarning(wpLog) + << "WallpaperService: could not make the pipe to wake on"; return; } @@ -658,7 +714,8 @@ void WallpaperService::wake() { char byte = 0; if (::write(m_wake[1], &byte, 1) < 0) - qWarning() << "WallpaperService: could not wake the drawing thread"; + qWarning(wpLog) + << "WallpaperService: could not wake the drawing thread"; } void WallpaperService::queue(WallpaperSurface* surface) { @@ -678,6 +735,23 @@ void WallpaperService::queueAll() { } void WallpaperService::loop() { + eglBindAPI(kGlesMode ? EGL_OPENGL_ES_API : EGL_OPENGL_API); + if (m_context == EGL_NO_CONTEXT) { + if (kGlesMode) { + const EGLint contextAttributes[] = { + EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + } else { + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, nullptr); + } + if (m_context == EGL_NO_CONTEXT) { + qWarning(wpLog) << "WallpaperService: eglCreateContext failed"; + return; + } + } + while (!m_stop) { present(); @@ -719,7 +793,12 @@ void WallpaperService::present() { addSurfaces(); - ensureDecodeSize(); + ensureImage(); + + if (m_isVideo && m_videoReady.load(std::memory_order_relaxed)) { + std::lock_guard lock(m_mutex); + m_replacementReady = true; + } QList surfaces; bool fading = false; @@ -730,12 +809,9 @@ void WallpaperService::present() { { std::lock_guard lock(m_mutex); - if (m_videoReload) { - m_videoReload = false; - videoReload = true; - path = m_path; - isVideo = m_isVideo; - } + videoReload = m_videoReload; + path = m_path; + isVideo = m_isVideo; fading = m_fading; const bool newVideoFrame = @@ -751,18 +827,59 @@ void WallpaperService::present() { if (m_context == EGL_NO_CONTEXT) return; - eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context); + EGLSurface importTarget = EGL_NO_SURFACE; + for (auto* surface : surfaces) + if (surface->eglSurface() != EGL_NO_SURFACE) { + importTarget = surface->eglSurface(); + break; + } + + if (importTarget == EGL_NO_SURFACE) return; + + if (!eglMakeCurrent(m_eglDisplay, importTarget, importTarget, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + if (videoReload) { + std::lock_guard lock(m_mutex); + m_videoReload = false; + } + + if (!m_orphanTextures.empty()) { + std::lock_guard lock(m_mutex); + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + } if (videoReload) { if (isVideo) startVideo(path); else if (m_mpv && !m_previousVideo) teardownMpv(); +#ifdef ZHAVE_VIDEO + else if (m_player && !m_previousVideo) { + m_player->deleteTexture(); + stopPlayerVideo(); + } +#endif } +#ifdef ZHAVE_VIDEO + const bool videoActive = (m_isVideo || m_previousVideo) && + (m_mpv != nullptr || m_player != nullptr); +#else const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr; +#endif - if (videoActive) pumpMpvEvents(); + if (videoActive && m_mpv != nullptr) pumpMpvEvents(); + +#ifdef ZHAVE_VIDEO + if (videoActive && m_player != nullptr) m_player->importCurrentFrame(); +#endif for (auto* surface : surfaces) drawSurface(surface); @@ -868,13 +985,13 @@ bool WallpaperService::setupGl() { } if (m_eglDisplay == EGL_NO_DISPLAY) { - qWarning() << "WallpaperService: no EGL display"; + qWarning(wpLog) << "WallpaperService: no EGL display"; return false; } EGLint major = 0, minor = 0; if (!eglInitialize(m_eglDisplay, &major, &minor)) { - qWarning() << "WallpaperService: eglInitialize failed"; + qWarning(wpLog) << "WallpaperService: eglInitialize failed"; return false; } @@ -882,7 +999,7 @@ bool WallpaperService::setupGl() { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, - EGL_OPENGL_ES2_BIT, + kGlesMode ? EGL_OPENGL_ES2_BIT : (EGL_OPENGL_BIT | EGL_OPENGL_ES2_BIT), EGL_RED_SIZE, 8, EGL_GREEN_SIZE, @@ -896,15 +1013,8 @@ bool WallpaperService::setupGl() { EGLint count = 0; if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || count == 0) { - qWarning() << "WallpaperService: no EGL config for window surfaces"; - return false; - } - - const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; - m_context = eglCreateContext( - m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); - if (m_context == EGL_NO_CONTEXT) { - qWarning() << "WallpaperService: eglCreateContext failed"; + qWarning(wpLog) + << "WallpaperService: no EGL config for window surfaces"; return false; } @@ -912,13 +1022,18 @@ bool WallpaperService::setupGl() { } uint32_t WallpaperService::createProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertex, 1, &kVertexShader, nullptr); + glShaderSource(vertex, 1, &vs, nullptr); glCompileShader(vertex); logShader(vertex, false); uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragment, 1, &kFragmentShader, nullptr); + glShaderSource(fragment, 1, &fs, nullptr); glCompileShader(fragment); logShader(fragment, false); @@ -930,9 +1045,10 @@ uint32_t WallpaperService::createProgram() { if (glGetUniformLocation(program, "size") < 0 || glGetUniformLocation(program, "crop") < 0 || - glGetUniformLocation(program, "alpha") < 0) { - qWarning() << "WallpaperService: the program does not have the " - "uniforms it needs"; + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "source") < 0) { + qWarning(wpLog) << "WallpaperService: the program does not have the " + "uniforms it needs"; glDeleteProgram(program); return 0; } @@ -940,12 +1056,137 @@ uint32_t WallpaperService::createProgram() { m_sizeUniform = glGetUniformLocation(program, "size"); m_cropUniform = glGetUniformLocation(program, "crop"); m_alphaUniform = glGetUniformLocation(program, "alpha"); + m_sourceUniform = glGetUniformLocation(program, "source"); + + glUseProgram(program); + glUniform1i(GLint(m_sourceUniform), 0); glDeleteShader(vertex); glDeleteShader(fragment); return program; } +#ifdef ZHAVE_VIDEO +uint32_t WallpaperService::createVideoProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper_video.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "srcSize") < 0 || + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "luma") < 0 || + glGetUniformLocation(program, "chroma") < 0 || + glGetUniformLocation(program, "matrix") < 0 || + glGetUniformLocation(program, "range") < 0 || + glGetUniformLocation(program, "chromaOff") < 0) { + qWarning(wpLog) + << "WallpaperService: the video program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_videoSizeUniform = glGetUniformLocation(program, "size"); + m_videoSrcSizeUniform = glGetUniformLocation(program, "srcSize"); + m_videoCropUniform = glGetUniformLocation(program, "crop"); + m_videoAlphaUniform = glGetUniformLocation(program, "alpha"); + m_videoLumaUniform = glGetUniformLocation(program, "luma"); + m_videoChromaUniform = glGetUniformLocation(program, "chroma"); + m_videoMatrixUniform = glGetUniformLocation(program, "matrix"); + m_videoRangeUniform = glGetUniformLocation(program, "range"); + m_videoChromaOffUniform = glGetUniformLocation(program, "chromaOff"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, lumaTex); + glUniform1i(GLint(m_videoLumaUniform), 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, chromaTex); + glUniform1i(GLint(m_videoChromaUniform), 1); + + glUniform1i(GLint(m_videoMatrixUniform), matrix); + glUniform1i(GLint(m_videoRangeUniform), range); + + glUniform2f( + GLint(m_videoChromaOffUniform), + range == 0 && srcW > 0 ? 0.5f / srcW : 0.0f, + 0.0f); + + glUniform2f(GLint(m_videoSizeUniform), sizeW, sizeH); + glUniform2f(GLint(m_videoSrcSizeUniform), srcW, srcH); + glUniform4f( + GLint(m_videoCropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_videoAlphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +bool WallpaperService::startPlayerVideo(const QString& path) { + if (!m_player) m_player = std::make_unique(m_eglDisplay); + + m_player->setReadyCallback([this]() { + m_videoLoaded.store(true, std::memory_order_relaxed); + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + m_player->setFrameCallback([this]() { + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + if (!m_player->start(path)) return false; + + return true; +} + +void WallpaperService::stopPlayerVideo() { + if (!m_player) return; + + m_player->stop(); + m_player.reset(); +} +#endif + void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { if (image.isNull()) return; @@ -981,7 +1222,9 @@ void WallpaperService::drawTexture( float sizeW, float sizeH, float alpha) { + glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(GLint(m_sourceUniform), 0); glUniform2f(GLint(m_sizeUniform), sizeW, sizeH); glUniform4f( GLint(m_cropUniform), @@ -994,70 +1237,161 @@ void WallpaperService::drawTexture( glDrawArrays(GL_TRIANGLES, 0, 3); } -bool WallpaperService::decode(const QString& path) { - if (path.isEmpty()) return false; +QByteArray WallpaperService::cachedBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + QString databasePath; + { + std::lock_guard lock(m_mutex); + databasePath = m_dbPath; + } + + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + const QString connection = QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT cache, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QImage WallpaperService::decodeImage( + const QString& path, const QSize& decodeSize) const { + if (path.isEmpty()) return {}; + + QImageReader probe(path); + const QSize source = probe.size(); + QSize required = source.isEmpty() ? decodeSize : source; + if (!source.isEmpty() && !decodeSize.isEmpty()) { + const QSize cover = + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && + cover.height() <= source.height()) + required = cover; + } + + const QByteArray blob = cachedBlob(path); + if (!blob.isEmpty()) { + QImage image = QImage::fromData(blob, "JPEG"); + if (!image.isNull() && image.width() >= required.width() && + image.height() >= required.height()) { + return image.convertToFormat(QImage::Format_RGBA8888); + } + } QImageReader reader(path); reader.setAutoTransform(true); - QSize size = reader.size(); - if (size.isValid()) m_sourceSize = size; - - if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) + if (!decodeSize.isEmpty() && !source.isEmpty()) reader.setScaledSize( - m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding)); QImage image = reader.read(); if (image.isNull()) { - qWarning() << "WallpaperService: could not read the image" << path; - return false; + qWarning(wpLog) << "WallpaperService: could not read the image" << path; + return {}; } - m_image = image.convertToFormat(QImage::Format_RGBA8888); - return !m_image.isNull(); + return image.convertToFormat(QImage::Format_RGBA8888); } -bool WallpaperService::ensureDecodeSize() { +void WallpaperService::ensureImage() { QSize largest; QString path; + bool needDecode = false; { std::lock_guard lock(m_mutex); - if (m_isVideo) return false; + if (m_isVideo) return; largest = largestBufferSize(); - if (largest.isEmpty()) return false; + if (largest.isEmpty()) return; - if (!m_forceDecode && !m_decodeSize.isEmpty() && - qint64(largest.width()) * largest.height() <= - qint64(m_decodeSize.width()) * m_decodeSize.height()) - return false; - - QSize target = - m_sourceSize.isEmpty() - ? largest - : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); - - if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) - return false; - - m_forceDecode = false; - m_decodeSize = largest; path = m_path; + + if (m_stagedReady && m_stagedPath == path) { + m_image = m_stagedImage; + m_stagedImage = QImage(); + m_stagedReady = false; + m_decodeSize = largest; + m_imageChanged = true; + m_replacementReady = true; + } else { + m_stagedReady = false; + m_stagedImage = QImage(); + + const bool grew = m_decodeSize.isEmpty() || + largest.width() > m_decodeSize.width() || + largest.height() > m_decodeSize.height(); + + if (!m_decoding && (m_forceDecode || path != m_decodePath || + m_image.isNull() || grew)) { + m_forceDecode = false; + m_decoding = true; + m_decodePath = path; + needDecode = true; + } else { + m_forceDecode = false; + } + } } - if (!decode(path)) return false; + if (!needDecode) return; + + QThreadPool::globalInstance()->start([this, path, largest]() { + const QImage image = decodeImage(path, largest); - { std::lock_guard lock(m_mutex); - m_imageChanged = true; - } + m_stagedImage = image; + m_stagedPath = path; + m_stagedReady = !image.isNull(); + m_decoding = false; + }); - return true; + wake(); } -QSize WallpaperService::largestBufferSize() { +void WallpaperService::invalidateCache() { + { + std::lock_guard lock(m_mutex); + if (m_isVideo) return; + m_forceDecode = true; + } + + wake(); +} + +QSize WallpaperService::largestBufferSize() const { + std::lock_guard lock(m_mutex); + QSize largest; for (auto* surface : m_surfaces) { @@ -1072,6 +1406,11 @@ QSize WallpaperService::largestBufferSize() { return largest; } +void WallpaperService::notifyBufferSizes() { + QMetaObject::invokeMethod( + this, "largestBufferSizeChanged", Qt::QueuedConnection); +} + QRectF WallpaperService::sourceRect( const QVariantMap& crops, const QString& screen, @@ -1125,12 +1464,19 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { return; if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { - qWarning() << "WallpaperService: the context cannot be made current"; + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); return; } eglSwapInterval(m_eglDisplay, 0); + if (m_vao == 0) { + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + } + struct ContextRelease { EGLDisplay display; ~ContextRelease() { @@ -1157,17 +1503,51 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; +#ifdef ZHAVE_VIDEO + const bool videoAsBackground = + (videoNow || (m_previousVideo && m_fading)) && + (m_mpv != nullptr || m_player != nullptr); +#else const bool videoAsBackground = (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr; +#endif if (videoAsBackground) { - if (!m_mpvRender) initMpvRender(); - if (!m_mpvRender) return; - glDisable(GL_BLEND); - glViewport(0, 0, size.width(), size.height()); - renderMpvToSurface(size); + +#ifdef ZHAVE_VIDEO + if (m_player) { + if (m_player->textureId() != 0) { + if (m_videoProgram == 0) + m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_player->frameSize(); + drawVideoTexture( + m_player->textureId(), + m_player->chromaTextureId(), + m_player->colorMatrix(), + m_player->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + float(size.width()), + float(size.height()), + src.width() > 0 ? float(src.width()) + : float(size.width()), + src.height() > 0 ? float(src.height()) + : float(size.height()), + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + renderMpvToSurface(size); + } } else { glUseProgram(m_program); glViewport(0, 0, size.width(), size.height()); @@ -1198,13 +1578,21 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { qreal progress = 1.0; if (m_fading) { - progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs); - if (progress >= 1.0) + progress = m_fadeClockStarted ? qreal(m_fadeTimer.elapsed()) / + qreal(kFadeDurationMs) + : 0.0; + + if (progress >= 1.0 && m_replacementReady) fadeDone = true; else fade = true; } + const float previousAlpha = m_replacementReady ? float(1.0 - progress) + : 1.0f; + const float replacementAlpha = m_replacementReady ? float(progress) + : 0.0f; + const float w = float(size.width()); const float h = float(size.height()); @@ -1221,11 +1609,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { m_previousImage.size()); drawTexture( - m_previousTexture, - previousRect, - w, - h, - 1.0f - float(progress)); + m_previousTexture, previousRect, w, h, previousAlpha); } } else if (fade && !m_firstFadeDone) { if (m_blackTex == 0) createBlackTexture(m_blackTex); @@ -1236,7 +1620,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { QRectF(0.0, 0.0, 1.0, 1.0), w, h, - 1.0f - float(progress)); + previousAlpha); } } else { const QRectF rect = @@ -1258,7 +1642,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { drawTexture(m_previousTexture, previousRect, w, h, 1.0f); } - drawTexture(m_texture, rect, w, h, float(progress)); + drawTexture(m_texture, rect, w, h, replacementAlpha); } else { drawTexture(m_texture, rect, w, h, 1.0f); } @@ -1270,6 +1654,12 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { if (m_previousVideo) { teardownMpv(); +#ifdef ZHAVE_VIDEO + if (m_player) { + m_player->deleteTexture(); + stopPlayerVideo(); + } +#endif m_previousVideo = false; } @@ -1285,7 +1675,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { surface->requestFrame(); if (!eglSwapBuffers(m_eglDisplay, target)) { - qWarning() + qWarning(wpLog) << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" << QString::number(unsigned(eglGetError()), 16); return; @@ -1309,15 +1699,15 @@ void WallpaperService::addSurface(const QString& screen) { } if (!qscreen) { - qWarning() << "WallpaperService:" << screen << "is not a screen"; + qWarning(wpLog) << "WallpaperService:" << screen << "is not a screen"; return; } auto* waylandScreen = dynamic_cast(qscreen->handle()); if (!waylandScreen || !waylandScreen->output()) { - qWarning() << "WallpaperService:" << screen - << "is not a Wayland screen"; + qWarning(wpLog) << "WallpaperService:" << screen + << "is not a Wayland screen"; return; } @@ -1331,6 +1721,8 @@ void WallpaperService::addSurface(const QString& screen) { std::lock_guard lock(m_mutex); m_surfaces.append(surface); } + + notifyBufferSizes(); } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp index d37b5b7..25cbd65 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -18,6 +18,11 @@ #include #include #include +#include + +#ifdef ZHAVE_VIDEO +#include "videoplayer.hpp" +#endif #include #include @@ -39,6 +44,10 @@ class WallpaperService : public QObject { Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + QSize largestBufferSize READ largestBufferSize NOTIFY + largestBufferSizeChanged) public: explicit WallpaperService(QObject* parent = nullptr); @@ -47,6 +56,11 @@ class WallpaperService : public QObject { [[nodiscard]] QString path() const; void setPath(const QString& path); + [[nodiscard]] QSize largestBufferSize() const; + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + void addScreen(const QString& screen); void removeScreen(const QString& screen); @@ -57,9 +71,13 @@ class WallpaperService : public QObject { Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + Q_INVOKABLE void invalidateCache(); + signals: void pathChanged(); void cropsChanged(); + void dbPathChanged(); + void largestBufferSizeChanged(); private: friend class WallpaperSurface; @@ -77,7 +95,6 @@ class WallpaperService : public QObject { void applyPath(const QString& path); void startVideo(const QString& path); - void stopVideo(); void teardownMpv(); void initMpvRender(); @@ -87,6 +104,23 @@ class WallpaperService : public QObject { static void* getMpvProcAddress(void* ctx, const char* name); static void mpvUpdate(void* ctx); +#ifdef ZHAVE_VIDEO + bool startPlayerVideo(const QString& path); + void stopPlayerVideo(); + [[nodiscard]] uint32_t createVideoProgram(); + void drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha); +#endif + void bindGlobals(); static void registryGlobal( void* data, @@ -111,16 +145,18 @@ class WallpaperService : public QObject { float sizeH, float alpha); - bool decode(const QString& path); + [[nodiscard]] QImage decodeImage( + const QString& path, const QSize& decodeSize) const; - bool ensureDecodeSize(); + [[nodiscard]] QByteArray cachedBlob(const QString& path) const; - [[nodiscard]] QSize largestBufferSize(); + void ensureImage(); + + void notifyBufferSizes(); void queue(WallpaperSurface* surface); void queueAll(); - void startDrawing(); void loop(); void present(); @@ -143,6 +179,7 @@ class WallpaperService : public QObject { void addSurface(const QString& screen); QString m_path; + QString m_dbPath; QStringList m_screens; QVariantMap m_crops; @@ -160,9 +197,15 @@ class WallpaperService : public QObject { QProcess* m_transcode = nullptr; - QSize m_sourceSize; QSize m_decodeSize; + QImage m_stagedImage; + QString m_stagedPath; + bool m_stagedReady = false; + bool m_decoding = false; + QString m_decodePath; + bool m_replacementReady = false; + wl_display* m_display = nullptr; wl_registry* m_registry = nullptr; wl_compositor* m_compositor = nullptr; @@ -178,10 +221,29 @@ class WallpaperService : public QObject { int m_sizeUniform = -1; int m_cropUniform = -1; int m_alphaUniform = -1; + int m_sourceUniform = -1; + + uint32_t m_videoProgram = 0; + uint32_t m_vao = 0; + int m_videoSizeUniform = -1; + int m_videoSrcSizeUniform = -1; + int m_videoCropUniform = -1; + int m_videoAlphaUniform = -1; + int m_videoLumaUniform = -1; + int m_videoChromaUniform = -1; + int m_videoMatrixUniform = -1; + int m_videoRangeUniform = -1; + int m_videoChromaOffUniform = -1; + +#ifdef ZHAVE_VIDEO + std::unique_ptr m_player; + bool m_usePlayer = true; +#endif QImage m_previousImage; QVariantMap m_previousCrops; uint32_t m_previousTexture = 0; + std::vector m_orphanTextures; bool m_fading = false; bool m_fadeClockStarted = false; bool m_firstFadeDone = false; diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp index 44c4297..1fb7834 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -185,6 +185,7 @@ void WallpaperSurface::updateBuffer() { requestFrame(); m_service->queue(this); + m_service->notifyBufferSizes(); } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt index 9bac1a8..6d677a0 100644 --- a/Plugins/ZShell/Models/CMakeLists.txt +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -2,7 +2,12 @@ qml_module(ZShell-models URI ZShell.Models SOURCES filesystemmodel.hpp filesystemmodel.cpp + wallpaperdb.hpp wallpaperdb.cpp + wallpaperutil.hpp LIBRARIES Qt::Gui + Qt::Quick + Qt::Qml Qt::Concurrent + Qt::Sql ) diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp index c7d7bad..72f423c 100644 --- a/Plugins/ZShell/Models/filesystemmodel.cpp +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace ZShell::models { @@ -39,136 +40,43 @@ bool hasVideoExtension(const QString& path) { return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); } -QString videoThumbnailPath(const QString& path) { - const QFileInfo info(path); +const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; - 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( +bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( "ffprobe", { "-v", "error", + "-select_streams", + "v:0", "-show_entries", - "format=duration", + "stream=codec_name", "-of", "default=nw=1:nk=1", - src, + path, }); - if (probe.first == 0) { - duration = QString::fromUtf8(probe.second).trimmed().toDouble(); - } + process.waitForFinished(5000); - const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) - : 8; - - QTemporaryDir tmpDir; - if (!tmpDir.isValid()) { + if (process.exitCode() != 0) { 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); + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); } FileSystemEntry::FileSystemEntry( @@ -177,7 +85,6 @@ FileSystemEntry::FileSystemEntry( , m_fileInfo(path) , m_path(path) , m_relativePath(relativePath) - , m_thumbnailPath(path) , m_isImageInitialised(false) , m_mimeTypeInitialised(false) {} @@ -227,16 +134,7 @@ bool FileSystemEntry::isVideo() const { } QString FileSystemEntry::thumbnailPath() const { - return m_thumbnailPath; -} - -void FileSystemEntry::setThumbnailPath(const QString& path) { - if (m_thumbnailPath == path) { - return; - } - - m_thumbnailPath = path; - emit thumbnailPathChanged(); + return m_path; } QString FileSystemEntry::mimeType() const { @@ -404,6 +302,46 @@ void FileSystemModel::setNameFilters(const QStringList& nameFilters) { update(); } +bool FileSystemModel::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void FileSystemModel::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + update(); +} + +bool FileSystemModel::isHardwareVideo(const QString& path) { + const QFileInfo info(path); + const QString key = + path + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch() / 1000); + + { + std::lock_guard lock(m_codecMutex); + + const auto it = m_codecCache.constFind(key); + if (it != m_codecCache.constEnd()) { + return it.value(); + } + } + + const bool hardware = probeHardwareCodec(path); + + { + std::lock_guard lock(m_codecMutex); + m_codecCache.insert(key, hardware); + } + + return hardware; +} + QQmlListProperty FileSystemModel::entries() { return QQmlListProperty(this, &m_entries); } @@ -434,14 +372,12 @@ void FileSystemModel::watchDirIfRecursive(const QString& path) { const auto paths = watcher->result(); if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) { - // Ignore if dir or showHidden has changed m_watcher.addPaths(paths); } watcher->deleteLater(); }); watcher->setFuture(future); } - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } void FileSystemModel::update() { @@ -488,6 +424,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { const auto showHidden = m_showHidden; const auto filter = m_filter; const auto nameFilters = m_nameFilters; + const auto requireHardware = m_requireHardwareCodec; QSet oldPaths; for (const auto& entry : std::as_const(m_entries)) { @@ -495,7 +432,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { } const auto future = QtConcurrent::run( - [=](QPromise, QSet>>& promise) { + [=, this](QPromise, QSet>>& promise) { const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; @@ -553,18 +490,15 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { QString path = iter->next(); - if (filter == Images) { + if (hasVideoExtension(path)) { + if (requireHardware && !isHardwareVideo(path)) { + continue; + } + } else if (filter == Images || filter == Media) { QImageReader reader(path); if (!reader.canRead()) { continue; } - } else if (filter == Media) { - if (!hasVideoExtension(path)) { - QImageReader reader(path); - if (!reader.canRead()) { - continue; - } - } } newPaths.insert(path); @@ -605,31 +539,6 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { }); watcher->setFuture(future); - // 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( @@ -642,7 +551,6 @@ void FileSystemModel::applyChanges( } std::sort(removedIndices.begin(), removedIndices.end(), std::greater()); - // Batch remove old entries int start = -1; int end = -1; for (int idx : std::as_const(removedIndices)) { @@ -670,14 +578,10 @@ void FileSystemModel::applyChanges( endRemoveRows(); } - // Create new entries QList newEntries; for (const auto& path : addedPaths) { auto* entry = new FileSystemEntry(path, m_dir.relativeFilePath(path), this); - if (entry->isVideo()) { - startThumbnailGeneration(entry); - } newEntries << entry; } std::sort( @@ -687,7 +591,6 @@ void FileSystemModel::applyChanges( return compareEntries(a, b); }); - // Batch insert new entries int insertStart = -1; QList batchItems; for (const auto& entry : std::as_const(newEntries)) { diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp index b55bb47..2cabeb9 100644 --- a/Plugins/ZShell/Models/filesystemmodel.hpp +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -29,8 +31,7 @@ class FileSystemEntry : public QObject { 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 thumbnailPath READ thumbnailPath CONSTANT) Q_PROPERTY(QString mimeType READ mimeType CONSTANT) public: @@ -52,20 +53,16 @@ class FileSystemEntry : public QObject { [[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; @@ -94,6 +91,9 @@ class FileSystemModel : public QAbstractListModel { Q_PROPERTY( QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) Q_PROPERTY( QQmlListProperty entries READ entries @@ -132,6 +132,9 @@ class FileSystemModel : public QAbstractListModel { [[nodiscard]] QStringList nameFilters() const; void setNameFilters(const QStringList& nameFilters); + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + [[nodiscard]] QQmlListProperty entries(); signals: @@ -142,6 +145,7 @@ class FileSystemModel : public QAbstractListModel { void sortReverseChanged(); void filterChanged(); void nameFiltersChanged(); + void requireHardwareCodecChanged(); void entriesChanged(); private: @@ -157,6 +161,10 @@ class FileSystemModel : public QAbstractListModel { bool m_sortReverse; Filter m_filter; QStringList m_nameFilters; + bool m_requireHardwareCodec = false; + + QHash m_codecCache; + QMutex m_codecMutex; void watchDirIfRecursive(const QString& path); void update(); @@ -165,7 +173,7 @@ class FileSystemModel : public QAbstractListModel { void updateEntriesForDir(const QString& dir); void applyChanges( const QSet& removedPaths, const QSet& addedPaths); - void startThumbnailGeneration(FileSystemEntry* entry); + [[nodiscard]] bool isHardwareVideo(const QString& path); [[nodiscard]] bool compareEntries( const FileSystemEntry* a, const FileSystemEntry* b) const; }; diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp new file mode 100644 index 0000000..25fe780 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -0,0 +1,746 @@ +#include "wallpaperdb.hpp" +#include "wallpaperutil.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(dbLog, "ZShell.Internal.wallpaper.db", QtWarningMsg) + +namespace ZShell::models { + +class WallpaperImageProvider : public QQuickImageProvider { + public: + explicit WallpaperImageProvider(const QString& dbPath) + : QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {} + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + const QString path = + QString::fromUtf8(QByteArray::fromHex(id.toUtf8())); + if (path.isEmpty() || !openThreadDb()) { + return {}; + } + + QSqlQuery query(QSqlDatabase::database(m_threadUuid)); + query.prepare("SELECT cache FROM wallpapers WHERE path = ?"); + query.addBindValue(path); + + if (!query.exec() || !query.next()) { + return {}; + } + + const QByteArray bytes = query.value(0).toByteArray(); + if (bytes.isEmpty()) { + return {}; + } + + QImage image = QImage::fromData(bytes, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (size) { + *size = image.size(); + } + + if (requestedSize.isValid() && !requestedSize.isEmpty() && + (image.width() > requestedSize.width() || + image.height() > requestedSize.height())) { + image = image.scaled( + requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + return image; + } + + private: + bool openThreadDb() { + if (!m_threadUuid.isEmpty()) { + return true; + } + + if (m_dbPath.isEmpty()) { + return false; + } + + const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + + auto db = QSqlDatabase::addDatabase("QSQLITE", uuid); + db.setConnectOptions("QSQLITE_OPEN_READONLY"); + db.setDatabaseName(m_dbPath); + + if (!db.open()) { + return false; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + + m_threadUuid = uuid; + return true; + } + + const QString m_dbPath; + thread_local static QString m_threadUuid; +}; + +thread_local QString WallpaperImageProvider::m_threadUuid; + +WallpaperEntry::WallpaperEntry( + const QString& path, const QString& relativePath, QObject* parent) + : QObject(parent) + , m_fileInfo(path) + , m_path(path) + , m_relativePath(relativePath) {} + +QString WallpaperEntry::path() const { + return m_path; +} + +QString WallpaperEntry::relativePath() const { + return m_relativePath; +} + +QString WallpaperEntry::name() const { + return m_fileInfo.fileName(); +} + +QString WallpaperEntry::baseName() const { + return m_fileInfo.baseName(); +} + +QString WallpaperEntry::parentDir() const { + return m_fileInfo.absolutePath(); +} + +QString WallpaperEntry::suffix() const { + return m_fileInfo.completeSuffix(); +} + +qint64 WallpaperEntry::size() const { + return m_fileInfo.size(); +} + +bool WallpaperEntry::isImage() const { + return !hasVideoExtension(m_path); +} + +bool WallpaperEntry::isVideo() const { + return hasVideoExtension(m_path); +} + +bool WallpaperEntry::hardware() const { + return m_hardware; +} + +bool WallpaperEntry::cached() const { + return !m_thumbnailPath.isEmpty(); +} + +QString WallpaperEntry::thumbnailPath() const { + if (!m_thumbnailPath.isEmpty()) { + return m_thumbnailPath; + } + + return isImage() ? m_path : QString(); +} + +void WallpaperEntry::setRelativePath(const QString& path) { + if (m_relativePath == path) { + return; + } + + m_relativePath = path; + emit relativePathChanged(); +} + +void WallpaperEntry::setHardware(bool hardware) { + if (m_hardware == hardware) { + return; + } + + m_hardware = hardware; + emit hardwareChanged(); +} + +void WallpaperEntry::setThumbnailPath(const QString& path) { + if (m_thumbnailPath == path) { + return; + } + + m_thumbnailPath = path; + emit thumbnailPathChanged(); +} + +WallpaperDb::WallpaperDb(QObject* parent) : QObject(parent) { + m_uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_dbPath = QDir::homePath() + "/.local/state/zshell/wallpapers.sqlite"; + + m_queueTimer.setInterval(300); + m_queueTimer.setTimerType(Qt::CoarseTimer); + QObject::connect( + &m_queueTimer, &QTimer::timeout, this, &WallpaperDb::processQueue); + + m_watchTimer.setInterval(400); + m_watchTimer.setSingleShot(true); + QObject::connect( + &m_watcher, &QFileSystemWatcher::directoryChanged, this, [this]() { + m_watchTimer.start(); + }); + QObject::connect( + &m_watchTimer, &QTimer::timeout, this, &WallpaperDb::scanDir); +} + +WallpaperDb::~WallpaperDb() { + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } +} + +QString WallpaperDb::path() const { + return m_path; +} + +void WallpaperDb::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + m_dir = QDir(path); + emit pathChanged(); + + for (const auto& dir : m_watcher.directories()) { + m_watcher.removePath(dir); + } + + scanDir(); +} + +QString WallpaperDb::dbPath() const { + return m_dbPath; +} + +void WallpaperDb::setDbPath(const QString& path) { + if (m_dbPath == path) { + return; + } + + m_dbPath = path; + emit dbPathChanged(); + + if (!m_path.isEmpty()) { + scanDir(); + } +} + +bool WallpaperDb::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void WallpaperDb::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + rebuildEntries(); +} + +QQmlListProperty WallpaperDb::entries() { + return QQmlListProperty(this, &m_entries); +} + +QString WallpaperDb::thumbnailUrl(const QString& path) { + return QStringLiteral("image: + + QString::fromUtf8(path.toUtf8().toHex()); +} + +QSize WallpaperDb::cacheSize() const { + return m_cacheSize; +} + +void WallpaperDb::setCacheSize(const QSize& cacheSize) { + if (m_cacheSize == cacheSize) { + return; + } + + const QSize prev = m_cacheSize; + const bool hadPrevious = !prev.isEmpty(); + + m_cacheSize = cacheSize; + emit cacheSizeChanged(); + + if (hadPrevious && qint64(cacheSize.width()) * cacheSize.height() > + qint64(prev.width()) * prev.height()) { + const auto db = QSqlDatabase::database(m_uuid, false); + + for (auto* entry : std::as_const(m_byPath)) { + if (!entry->cached()) { + continue; + } + + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = NULL, scheme_thumb = NULL" + " WHERE path = ?"); + query.addBindValue(entry->path()); + query.exec(); + } + + entry->setThumbnailPath(QString()); + queueMetadata(entry, entry->isVideo()); + } + + rebuildEntries(); + } + + if (!m_queue.isEmpty() && !m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::registerProvider() { + if (m_provider != nullptr) { + return; + } + + auto* engine = qmlEngine(this); + if (engine == nullptr) { + return; + } + + m_provider = new WallpaperImageProvider(m_dbPath); + engine->addImageProvider(QStringLiteral("wallpapers"), m_provider); +} + +void WallpaperDb::openDb() { + if (m_dbPath.isEmpty()) { + return; + } + + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen() && db.databaseName() == m_dbPath) { + registerProvider(); + return; + } + + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } + + QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); + + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(m_dbPath); + + if (!db.open()) { + qWarning(dbLog) << "WallpaperDb: could not open database" << m_dbPath + << db.lastError(); + return; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + query.exec( + "CREATE TABLE IF NOT EXISTS wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + + query.exec("SELECT cache FROM wallpapers LIMIT 1"); + if (query.lastError().isValid()) { + query.exec("DROP TABLE IF EXISTS wallpapers"); + query.exec( + "CREATE TABLE wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + } else { + query.exec("ALTER TABLE wallpapers ADD COLUMN scheme_thumb BLOB"); + } + + registerProvider(); +} + +void WallpaperDb::scanDir() { + openDb(); + + watchDir(m_dir.absolutePath()); + + if (!m_dir.exists()) { + if (!m_entries.isEmpty()) { + m_entries.clear(); + emit entriesChanged(); + } + + return; + } + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, &QFutureWatcher::finished, this, [this, watcher]() { + applyScan(watcher->result()); + watcher->deleteLater(); + }); + + const QDir dir = m_dir; + + QHash> knownCodecs; + for (const auto* entry : std::as_const(m_byPath)) { + if (entry->isVideo()) { + knownCodecs.insert( + entry->path(), qMakePair(entry->size(), entry->hardware())); + } + } + + watcher->setFuture(QtConcurrent::run([dir, knownCodecs]() { + ScanResult result; + result.dir = dir; + + QDirIterator it(dir.absolutePath(), QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + const QFileInfo info(path); + + if (info.isDir()) { + continue; + } + + if (hasVideoExtension(path)) { + FileEntry file; + file.info = info; + + const auto known = knownCodecs.constFind(path); + if (known != knownCodecs.constEnd() && + known.value().first == info.size()) { + file.hardware = known.value().second; + } else { + file.hardware = probeHardwareCodec(path); + } + + result.files.append(file); + } else { + QImageReader reader(path); + if (reader.canRead()) { + FileEntry file; + file.info = info; + result.files.append(file); + } + } + } + + return result; + })); +} + +void WallpaperDb::watchDir(const QString& dir) { + if (dir.isEmpty() || m_watcher.directories().contains(dir)) { + return; + } + + if (!QDir(dir).exists()) { + return; + } + + m_watcher.addPath(dir); +} + +void WallpaperDb::applyScan(const ScanResult& result) { + qCInfo( + dbLog, + "WallpaperDb: scan of %s found %d files (cache target %dx%d)", + m_dir.absolutePath().toUtf8().constData(), + int(result.files.size()), + m_cacheSize.width(), + m_cacheSize.height()); + + const auto db = QSqlDatabase::database(m_uuid, false); + + QSet seen; + for (const auto& file : result.files) { + seen.insert(file.info.absoluteFilePath()); + } + + for (auto it = m_byPath.begin(); it != m_byPath.end();) { + if (!seen.contains(it.key())) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare("DELETE FROM wallpapers WHERE path = ?"); + query.addBindValue(it.key()); + query.exec(); + } + + delete it.value(); + it = m_byPath.erase(it); + } else { + ++it; + } + } + + for (const auto& file : result.files) { + const QFileInfo& info = file.info; + const QString path = info.absoluteFilePath(); + + const qint64 size = info.size(); + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + const bool isVideo = hasVideoExtension(path); + + watchDir(info.absolutePath()); + + const auto existing = m_byPath.constFind(path); + if (existing != m_byPath.constEnd()) { + if (existing.value()->size() == size) { + existing.value()->setHardware(file.hardware); + continue; + } + } + + WallpaperEntry* entry = m_byPath.value(path); + if (!entry) { + entry = + new WallpaperEntry(path, m_dir.relativeFilePath(path), this); + m_byPath.insert(path, entry); + } + + entry->setHardware(file.hardware); + + bool reused = false; + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "SELECT cache, scheme_thumb, size, mtime FROM wallpapers" + " WHERE path = ?"); + query.addBindValue(path); + query.exec(); + + if (query.next()) { + const auto cache = query.value(0).toByteArray(); + const auto scheme = query.value(1).toByteArray(); + const auto storedSize = query.value(2).toLongLong(); + const auto storedMtime = query.value(3).toLongLong(); + + if (storedSize == size && storedMtime == mtime && + !cache.isEmpty()) { + reused = true; + entry->setThumbnailPath(thumbnailUrl(path)); + + if (scheme.isEmpty()) { + queueMetadata(entry, isVideo, cache); + } + } + } + } + + if (!reused) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "INSERT OR REPLACE INTO wallpapers" + " (path, kind, hardware, size, mtime, cache)" + " VALUES (?, ?, ?, ?, ?, NULL)"); + query.addBindValue(path); + query.addBindValue(isVideo ? "video" : "image"); + query.addBindValue( + isVideo ? (file.hardware ? 1 : 0) : QVariant()); + query.addBindValue(size); + query.addBindValue(mtime); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the row for" << path + << query.lastError(); + } + } + + queueMetadata(entry, isVideo); + } + } + + rebuildEntries(); +} + +void WallpaperDb::queueMetadata( + WallpaperEntry* entry, bool isVideo, const QByteArray& existingCache) { + qCInfo( + dbLog, + "WallpaperDb: queued cache job for %s (%s%s)", + entry->path().toUtf8().constData(), + isVideo ? "video" : "image", + existingCache.isEmpty() ? "" : ", existing blob"); + + m_queue.append(MetadataJob{entry, entry->path(), isVideo, existingCache}); + + if (!m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::processQueue() { + if (m_jobRunning) { + return; + } + + if (m_queue.isEmpty()) { + m_queueTimer.stop(); + return; + } + + if (m_cacheSize.isEmpty()) { + return; + } + + const MetadataJob job = m_queue.takeFirst(); + m_jobRunning = true; + + qCInfo( + dbLog, + "WallpaperDb: caching %s (%s, %d jobs left in queue)", + job.path.toUtf8().constData(), + job.isVideo ? "video" : "image", + int(m_queue.size())); + + runMetadataJob(job); +} + +void WallpaperDb::runMetadataJob(const MetadataJob& job) { + struct CacheResult { + QString path; + QByteArray cache; + QByteArray scheme; + }; + + const QSize cacheSize = m_cacheSize; + + QElapsedTimer clock; + clock.start(); + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, + &QFutureWatcher::finished, + this, + [this, watcher, clock]() { + const auto result = watcher->result(); + + qCInfo( + dbLog, + "WallpaperDb: %s produced %d bytes of cache in %dms", + result.path.toUtf8().constData(), + int(result.cache.size()), + int(clock.elapsed())); + + if (QSqlDatabase::contains(m_uuid)) { + const auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = ?, scheme_thumb = ?" + " WHERE path = ?"); + query.addBindValue(result.cache); + query.addBindValue(result.scheme); + query.addBindValue(result.path); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the cache for" + << result.path << query.lastError(); + } + } + } + + if (!result.cache.isEmpty()) { + if (auto* entry = m_byPath.value(result.path)) { + entry->setThumbnailPath(thumbnailUrl(result.path)); + } + + emit cacheStored(result.path); + } + + m_jobRunning = false; + rebuildEntries(); + watcher->deleteLater(); + }); + + watcher->setFuture(QtConcurrent::run([job, cacheSize]() { + CacheResult result; + result.path = job.path; + + QByteArray cache = job.existingCache; + if (cache.isEmpty()) { + cache = job.isVideo ? videoCacheBytes(job.path, cacheSize) + : imageCacheBytes(job.path, cacheSize); + } + + result.cache = cache; + result.scheme = cache.isEmpty() ? QByteArray() + : schemeThumbBytes(cache, 128); + + return result; + })); +} + +void WallpaperDb::rebuildEntries() { + QList next; + next.reserve(m_byPath.size()); + + for (auto* entry : std::as_const(m_byPath)) { + if (entryAllowed(entry)) { + next.append(entry); + } + } + + std::sort( + next.begin(), + next.end(), + [](const WallpaperEntry* a, const WallpaperEntry* b) { + return a->relativePath() < b->relativePath(); + }); + + if (next == m_entries) { + return; + } + + m_entries = next; + emit entriesChanged(); +} + +bool WallpaperDb::entryAllowed(const WallpaperEntry* entry) const { + if (!entry->isVideo()) { + return true; + } + + if (!m_requireHardwareCodec) { + return true; + } + + return entry->hardware(); +} + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperdb.hpp b/Plugins/ZShell/Models/wallpaperdb.hpp new file mode 100644 index 0000000..30592e4 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.hpp @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::models { + +class WallpaperImageProvider; + +class WallpaperEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE( + "WallpaperEntry instances can only be retrieved from a WallpaperDb") + + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString baseName READ baseName CONSTANT) + Q_PROPERTY(QString parentDir READ parentDir CONSTANT) + Q_PROPERTY(QString suffix READ suffix CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(bool isVideo READ isVideo CONSTANT) + Q_PROPERTY(bool hardware READ hardware NOTIFY hardwareChanged) + Q_PROPERTY( + QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged) + + public: + explicit WallpaperEntry( + const QString& path, + const QString& relativePath, + QObject* parent = nullptr); + + [[nodiscard]] QString path() const; + [[nodiscard]] QString relativePath() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString baseName() const; + [[nodiscard]] QString parentDir() const; + [[nodiscard]] QString suffix() const; + [[nodiscard]] qint64 size() const; + [[nodiscard]] bool isImage() const; + [[nodiscard]] bool isVideo() const; + [[nodiscard]] bool hardware() const; + [[nodiscard]] bool cached() const; + [[nodiscard]] QString thumbnailPath() const; + + void setRelativePath(const QString& path); + void setHardware(bool hardware); + void setThumbnailPath(const QString& path); + + signals: + void relativePathChanged(); + void hardwareChanged(); + void thumbnailPathChanged(); + + private: + const QFileInfo m_fileInfo; + + const QString m_path; + QString m_relativePath; + QString m_thumbnailPath; + bool m_hardware = false; +}; + +class WallpaperDb : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) + Q_PROPERTY( + QQmlListProperty entries READ entries + NOTIFY entriesChanged) + Q_PROPERTY( + QSize cacheSize READ cacheSize WRITE setCacheSize NOTIFY + cacheSizeChanged) + + public: + explicit WallpaperDb(QObject* parent = nullptr); + ~WallpaperDb() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + + [[nodiscard]] QQmlListProperty entries(); + + [[nodiscard]] QSize cacheSize() const; + void setCacheSize(const QSize& cacheSize); + + [[nodiscard]] static QString thumbnailUrl(const QString& path); + + signals: + void pathChanged(); + void dbPathChanged(); + void requireHardwareCodecChanged(); + void entriesChanged(); + void cacheSizeChanged(); + + void cacheStored(const QString& path); + + private: + struct FileEntry { + QFileInfo info; + bool hardware = false; + }; + + struct ScanResult { + QVector files; + QDir dir; + }; + + struct MetadataJob { + WallpaperEntry* entry; + QString path; + bool isVideo; + QByteArray existingCache; + }; + + void openDb(); + void registerProvider(); + void scanDir(); + void watchDir(const QString& dir); + void applyScan(const ScanResult& result); + void queueMetadata( + WallpaperEntry* entry, + bool isVideo, + const QByteArray& existingCache = {}); + void processQueue(); + void runMetadataJob(const MetadataJob& job); + void rebuildEntries(); + + [[nodiscard]] bool entryAllowed(const WallpaperEntry* entry) const; + + QDir m_dir; + QString m_path; + QString m_dbPath; + QString m_uuid; + WallpaperImageProvider* m_provider = nullptr; + + bool m_requireHardwareCodec = false; + QSize m_cacheSize; + + QList m_entries; + QHash m_byPath; + + QVector m_queue; + QTimer m_queueTimer; + bool m_jobRunning = false; + + QFileSystemWatcher m_watcher; + QTimer m_watchTimer; +}; + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperutil.hpp b/Plugins/ZShell/Models/wallpaperutil.hpp new file mode 100644 index 0000000..d5b3a13 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperutil.hpp @@ -0,0 +1,313 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ZShell::models { +namespace { + +inline const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +inline bool hasVideoExtension(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +inline const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; + +inline bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + process.waitForFinished(5000); + + if (process.exitCode() != 0) { + return false; + } + + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); +} + +inline QSize coverSizeNoUpscale(const QSize& source, const QSize& fit) { + if (!source.isValid() || source.isEmpty() || !fit.isValid() || + fit.isEmpty()) { + return source; + } + + const QSize cover = source.scaled(fit, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && cover.height() <= source.height()) { + return cover; + } + + return source; +} + +inline QImage cleanImage(const QImage& src) { + QImage out(src.size(), QImage::Format_RGB888); + if (out.isNull()) { + return out; + } + + const auto rowBytes = + static_cast(std::min(src.bytesPerLine(), out.bytesPerLine())); + for (int y = 0; y < src.height(); ++y) { + std::memcpy(out.scanLine(y), src.constScanLine(y), rowBytes); + } + + return out; +} + +inline QByteArray imageCacheBytes(const QString& path, const QSize& fit) { + QImageReader reader(path); + reader.setAutoTransform(true); + + const QSize source = reader.size(); + const QSize target = coverSizeNoUpscale(source, fit); + if (target != source && !target.isEmpty()) { + reader.setScaledSize(target); + } + + QImage image = reader.read(); + if (image.isNull()) { + return {}; + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 90)) { + return {}; + } + + return bytes; +} + +inline QByteArray videoCacheBytes(const QString& src, const QSize& fit) { + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + if (!process.waitForFinished(15000)) { + process.kill(); + process.waitForFinished(1000); + } + 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(); + } + + QSize source; + const auto resProbe = runProcess( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + src, + }); + if (resProbe.first == 0) { + const auto parts = + QString::fromUtf8(resProbe.second).trimmed().split('x'); + if (parts.size() == 2) { + source = QSize(parts[0].toInt(), parts[1].toInt()); + } + } + + const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) + : 8; + + QSize target = coverSizeNoUpscale(source, fit); + if (target.isEmpty()) { + target = fit; + } + + target.setWidth(target.width() & ~1); + target.setHeight(target.height() & ~1); + + const QString scale = + QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2") + .arg(target.width()) + .arg(target.height()); + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return {}; + } + + 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 {}; + } + + QFile frame(tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex))); + if (!frame.open(QIODevice::ReadOnly)) { + return {}; + } + + return frame.readAll(); +} + +inline QByteArray schemeThumbBytes(const QByteArray& source, int maxSide) { + QImage image = QImage::fromData(source, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (image.width() > maxSide || image.height() > maxSide) { + image = image.scaled( + maxSide, maxSide, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 85)) { + return {}; + } + + return bytes; +} + +} // namespace + +} // namespace ZShell::models diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index adc36c2..5c1fc01 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -1,8 +1,10 @@ import contextlib +import io import json import os import re import shutil +import sqlite3 import subprocess import sys import tempfile @@ -50,6 +52,30 @@ VIDEO_EXTENSIONS = { } +def scheme_thumb_from_db(db_path: str, wallpaper_path: str) -> bytes | None: + if not os.path.exists(db_path): + return None + + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return None + + try: + row = conn.execute( + "SELECT scheme_thumb FROM wallpapers WHERE path = ?", + (wallpaper_path,), + ).fetchone() + except sqlite3.Error: + return None + finally: + conn.close() + + if row and row[0]: + return bytes(row[0]) + return None + + def _complete_scheme_name(incomplete): schemes = [ "fruit-salad", @@ -181,6 +207,7 @@ def generate( OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails") + DB_PATH = Path(HOME + "/.local/state/zshell/wallpapers.sqlite") WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json") TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates") @@ -808,13 +835,20 @@ def generate( seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) else: image_path = image_path or Path(WALL_PATH) - thumb_path = generate_thumbnail(image_path, THUMB_DIR) - seed = seed_from_image(thumb_path) + + blob = scheme_thumb_from_db(str(DB_PATH), str(image_path)) + if blob is not None: + open_thumb = lambda: io.BytesIO(blob) + else: + thumb_path = generate_thumbnail(image_path, THUMB_DIR) + open_thumb = lambda: thumb_path + + seed = seed_from_image(open_thumb()) name = "dynamic" flavor = "default" if smart: - effective_mode = smart_mode(thumb_path) + effective_mode = smart_mode(open_thumb()) elif mode is not None: effective_mode = mode else: