diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ee63ca..42f45ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,14 @@ pkg_check_modules( hyprutils>=0.11.0 sdbus-c++>=2.0.0 hyprgraphics>=0.1.6) + +option(VIDEO_BACKEND "Enable video background support via FFmpeg" ON) +if(VIDEO_BACKEND) + pkg_check_modules(ffmpeg REQUIRED IMPORTED_TARGET libavcodec libavformat libavutil libswscale) + message(STATUS "Video backend: enabled") +else() + message(STATUS "Video backend: disabled") +endif() find_library(PAM_FOUND NAMES pam libpam) if(PAM_FOUND) set(PAM_LIB ${PAM_FOUND}) @@ -104,10 +112,20 @@ endif() message(STATUS "Found pam at ${PAM_LIB}") file(GLOB_RECURSE SRCFILES CONFIGURE_DEPENDS "src/*.cpp") + +if(NOT VIDEO_BACKEND) + list(FILTER SRCFILES EXCLUDE REGEX ".*/VideoBackend\\.cpp$") +endif() + add_executable(hyprlock ${SRCFILES}) target_link_libraries(hyprlock PRIVATE ${PAM_LIB} rt Threads::Threads PkgConfig::deps OpenGL::EGL OpenGL::GLES3) +if(VIDEO_BACKEND) + target_compile_definitions(hyprlock PRIVATE HYPRLOCK_HAS_VIDEO) + target_link_libraries(hyprlock PRIVATE PkgConfig::ffmpeg) +endif() + # protocols pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir) message(STATUS "Found wayland-protocols at ${WAYLAND_PROTOCOLS_DIR}") diff --git a/nix/default.nix b/nix/default.nix index 58bc834..774da9d 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -4,6 +4,7 @@ cmake, pkg-config, cairo, + ffmpeg ? null, libdrm, libGL, libxkbcommon, @@ -19,6 +20,7 @@ wayland, wayland-protocols, wayland-scanner, + withVideoBackend ? true, version ? "git", shortRev ? "", }: @@ -50,12 +52,12 @@ stdenv.mkDerivation { systemdLibs wayland wayland-protocols - ]; + ] ++ lib.optionals withVideoBackend [ ffmpeg ]; cmakeFlags = lib.mapAttrsToList lib.cmakeFeature { HYPRLOCK_COMMIT = shortRev; HYPRLOCK_VERSION_COMMIT = ""; # Intentionally left empty (hyprlock --version will always print the commit) - }; + } ++ lib.optional (!withVideoBackend) (lib.cmakeBool "VIDEO_BACKEND" false); meta = { homepage = "https://github.com/hyprwm/hyprlock"; diff --git a/src/renderer/VideoBackend.cpp b/src/renderer/VideoBackend.cpp new file mode 100644 index 0000000..511215f --- /dev/null +++ b/src/renderer/VideoBackend.cpp @@ -0,0 +1,177 @@ +#include "VideoBackend.hpp" +#include "../helpers/Log.hpp" +#include + +CVideoBackend::~CVideoBackend() { + stop(); +} + +bool CVideoBackend::isVideoFile(const std::string& path) { + static const std::unordered_set VIDEO_EXT = { + ".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v", + ".flv", ".wmv", ".ts", ".m2ts", ".gif" + }; + auto ext = std::filesystem::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + return VIDEO_EXT.count(ext) > 0; +} + +bool CVideoBackend::open(const std::string& path) { + if (avformat_open_input(&m_formatCtx, path.c_str(), nullptr, nullptr) < 0) { + Log::logger->log(Log::ERR, "CVideoBackend: avformat_open_input failed for {}", path); + return false; + } + if (avformat_find_stream_info(m_formatCtx, nullptr) < 0) { + Log::logger->log(Log::ERR, "CVideoBackend: avformat_find_stream_info failed for {}", path); + return false; + } + + const AVCodec* codec = nullptr; + m_streamIdx = av_find_best_stream(m_formatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0); + if (m_streamIdx < 0 || !codec) { + Log::logger->log(Log::ERR, "CVideoBackend: no video stream found in {}", path); + return false; + } + + m_codecCtx = avcodec_alloc_context3(codec); + if (!m_codecCtx) { + Log::logger->log(Log::ERR, "CVideoBackend: avcodec_alloc_context3 failed"); + return false; + } + + avcodec_parameters_to_context(m_codecCtx, m_formatCtx->streams[m_streamIdx]->codecpar); + + if (avcodec_open2(m_codecCtx, codec, nullptr) < 0) { + Log::logger->log(Log::ERR, "CVideoBackend: avcodec_open2 failed for {}", path); + return false; + } + + m_frameW = m_codecCtx->width; + m_frameH = m_codecCtx->height; + m_timeBase = av_q2d(m_formatCtx->streams[m_streamIdx]->time_base); + + // swsCtx is created lazily per-frame via sws_getCachedContext so that + // we handle codecs where pix_fmt is only known after the first decode. + m_frameData.resize(4 * m_frameW * m_frameH); + + Log::logger->log(Log::INFO, "CVideoBackend: opened {} ({}x{}, timebase={:.6f})", + path, m_frameW, m_frameH, m_timeBase); + + startDecodeThread(); + return true; +} + +void CVideoBackend::stop() { + m_running = false; + if (m_decodeThread.joinable()) + m_decodeThread.join(); + if (m_swsCtx) sws_freeContext(m_swsCtx); + if (m_codecCtx) avcodec_free_context(&m_codecCtx); + if (m_formatCtx) avformat_close_input(&m_formatCtx); + m_swsCtx = nullptr; + m_codecCtx = nullptr; + m_formatCtx = nullptr; +} + +bool CVideoBackend::swapFrame(std::vector& buf) { + std::lock_guard lock(m_frameMutex); + if (!m_hasNewFrame) + return false; + std::swap(m_frameData, buf); + m_hasNewFrame = false; + return true; +} + +void CVideoBackend::startDecodeThread() { + m_running = true; + m_startTime = std::chrono::steady_clock::now(); + + m_decodeThread = std::thread([this]() { + AVPacket* pkt = av_packet_alloc(); + AVFrame* frame = av_frame_alloc(); + // Pre-size the tmp buffer so it's never empty when swapping with m_frameData. + // An empty vector has data()==null which causes "bad dst image pointers" in sws_scale. + std::vector tmpBuf(4 * m_frameW * m_frameH); + + while (m_running) { + int ret = av_read_frame(m_formatCtx, pkt); + + if (ret == AVERROR_EOF) { + // Flush decoder's internal buffer + avcodec_send_packet(m_codecCtx, nullptr); + while (avcodec_receive_frame(m_codecCtx, frame) == 0) + av_frame_unref(frame); + + // Loop: seek back to beginning + av_seek_frame(m_formatCtx, m_streamIdx, 0, AVSEEK_FLAG_BACKWARD); + avcodec_flush_buffers(m_codecCtx); + m_startTime = std::chrono::steady_clock::now(); + continue; + } + + if (ret < 0) + break; // unrecoverable error + + if (pkt->stream_index != m_streamIdx) { + av_packet_unref(pkt); + continue; + } + + if (avcodec_send_packet(m_codecCtx, pkt) < 0) { + av_packet_unref(pkt); + continue; + } + av_packet_unref(pkt); + + while (avcodec_receive_frame(m_codecCtx, frame) == 0) { + if (!m_running) + break; + + // Lazily create/update SwsContext to match the frame's actual pixel + // format (some codecs only report it after the first frame). + m_swsCtx = sws_getCachedContext(m_swsCtx, + frame->width, frame->height, (AVPixelFormat)frame->format, + m_frameW, m_frameH, AV_PIX_FMT_RGBA, + SWS_BILINEAR, nullptr, nullptr, nullptr); + if (!m_swsCtx) { + av_frame_unref(frame); + continue; + } + + // sws_scale requires 4-element pointer/stride arrays even for + // packed formats — passing a 1-element array causes UB reads. + uint8_t* dst[4] = {tmpBuf.data(), nullptr, nullptr, nullptr}; + int stride[4] = {4 * m_frameW, 0, 0, 0}; + sws_scale(m_swsCtx, + (const uint8_t* const*)frame->data, frame->linesize, + 0, frame->height, dst, stride); + + // Publish frame via O(1) swap (no memcpy) + { + std::lock_guard lock(m_frameMutex); + std::swap(m_frameData, tmpBuf); + m_hasNewFrame = true; + } + // tmpBuf now holds old frame data — overwritten next iteration + + // PTS-based frame pacing + if (frame->pts != AV_NOPTS_VALUE) { + double pts_sec = frame->pts * m_timeBase; + auto target = m_startTime + + std::chrono::duration_cast( + std::chrono::duration(pts_sec)); + auto now = std::chrono::steady_clock::now(); + // Safety cap: never sleep > 5s (guards against bogus PTS values) + auto maxTarget = now + std::chrono::seconds(5); + if (target > now && target < maxTarget) + std::this_thread::sleep_until(target); + } + + av_frame_unref(frame); + } + } + + av_packet_free(&pkt); + av_frame_free(&frame); + }); +} diff --git a/src/renderer/VideoBackend.hpp b/src/renderer/VideoBackend.hpp new file mode 100644 index 0000000..ba90520 --- /dev/null +++ b/src/renderer/VideoBackend.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +// Handles FFmpeg video decoding on a background thread. +// CBackground owns one of these when path is a video file. +// All GL upload and rendering stays in CBackground. +class CVideoBackend { + public: + ~CVideoBackend(); + + // Returns true if the file extension is a recognised video format. + static bool isVideoFile(const std::string& path); + + // Open the file and start the decode thread. Returns false on failure. + bool open(const std::string& path); + + // Stop the decode thread and release all FFmpeg resources. + void stop(); + + // Swap the latest decoded RGBA frame into buf (O(1), no memcpy). + // Returns true if a new frame was available and buf was updated. + bool swapFrame(std::vector& buf); + + int frameW() const { return m_frameW; } + int frameH() const { return m_frameH; } + bool isRunning() const { return m_running; } + + private: + void startDecodeThread(); + + AVFormatContext* m_formatCtx = nullptr; + AVCodecContext* m_codecCtx = nullptr; + SwsContext* m_swsCtx = nullptr; + int m_streamIdx = -1; + int m_frameW = 0; + int m_frameH = 0; + double m_timeBase = 0.0; + + std::mutex m_frameMutex; + std::vector m_frameData; + bool m_hasNewFrame = false; + + std::chrono::steady_clock::time_point m_startTime; + + std::thread m_decodeThread; + std::atomic m_running{false}; +}; diff --git a/src/renderer/widgets/Background.cpp b/src/renderer/widgets/Background.cpp index 3b921e7..8368ebb 100644 --- a/src/renderer/widgets/Background.cpp +++ b/src/renderer/widgets/Background.cpp @@ -7,7 +7,6 @@ #include "../../helpers/MiscFunctions.hpp" #include "../../core/AnimationManager.hpp" #include "../../config/ConfigManager.hpp" -#include #include #include #include @@ -40,6 +39,7 @@ static std::string runAndGetPath(const std::string& reloadCommand) { return path; } + void CBackground::configure(const std::unordered_map& props, const SP& pOutput) { reset(); @@ -86,10 +86,29 @@ void CBackground::configure(const std::unordered_map& pro Log::logger->log(Log::ERR, "No screencopy support! path=screenshot won't work. Falling back to background color."); resourceID = 0; } - } else if (!path.empty()) - resourceID = g_asyncResourceManager->requestImage(path, m_imageRevision, nullptr); + } else if (!path.empty()) { +#ifdef HYPRLOCK_HAS_VIDEO + if (CVideoBackend::isVideoFile(path)) { + m_videoBackend = makeUnique(); + if (!m_videoBackend->open(path)) { + Log::logger->log(Log::ERR, "CBackground: failed to open '{}' as video, falling back to image", path); + m_videoBackend.reset(); + resourceID = g_asyncResourceManager->requestImage(path, m_imageRevision, nullptr); + } else { + m_uploadBuffer.resize(4 * m_videoBackend->frameW() * m_videoBackend->frameH()); + } + } else +#endif + { + resourceID = g_asyncResourceManager->requestImage(path, m_imageRevision, nullptr); + } + } - if (!reloadCommand.empty() && reloadTime > -1) { + if (!reloadCommand.empty() && reloadTime > -1 +#ifdef HYPRLOCK_HAS_VIDEO + && !m_videoBackend +#endif + ) { try { if (!isScreenshot) modificationTime = std::filesystem::last_write_time(absolutePath(path, "")); @@ -100,6 +119,14 @@ void CBackground::configure(const std::unordered_map& pro } void CBackground::reset() { +#ifdef HYPRLOCK_HAS_VIDEO + if (m_videoBackend) { + m_videoBackend.reset(); + m_videoTexture.destroyTexture(); + m_uploadBuffer.clear(); + } +#endif + if (reloadTimer) { reloadTimer->cancel(); reloadTimer.reset(); @@ -225,6 +252,52 @@ void CBackground::renderToFB(const CTexture& tex, CFramebuffer& fb, int passes, } bool CBackground::draw(const SRenderData& data) { +#ifdef HYPRLOCK_HAS_VIDEO + // ── Video background fast path ──────────────────────────────────────── + if (m_videoBackend) { + if (m_videoBackend->swapFrame(m_uploadBuffer)) { + const int W = m_videoBackend->frameW(); + const int H = m_videoBackend->frameH(); + + if (!m_videoTexture.m_bAllocated) { + // First frame: allocate the GL texture + m_videoTexture.allocate(); + glBindTexture(GL_TEXTURE_2D, m_videoTexture.m_iTexID); + 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); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, W, H, 0, + GL_RGBA, GL_UNSIGNED_BYTE, m_uploadBuffer.data()); + glBindTexture(GL_TEXTURE_2D, 0); + m_videoTexture.m_vSize = {(double)W, (double)H}; + m_videoTexture.m_iType = TEXTURE_RGBA; + m_videoTexture.m_iTarget = GL_TEXTURE_2D; + } else { + glBindTexture(GL_TEXTURE_2D, m_videoTexture.m_iTexID); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, W, H, + GL_RGBA, GL_UNSIGNED_BYTE, m_uploadBuffer.data()); + glBindTexture(GL_TEXTURE_2D, 0); + } + + if (blurPasses > 0) + renderToFB(m_videoTexture, *blurredFB, blurPasses); + } + + if (!m_videoTexture.m_bAllocated) { + renderRect(color); // solid colour until first frame is ready + return true; + } + + const CTexture& TEX = (blurPasses > 0 && blurredFB->isAllocated()) + ? blurredFB->m_cTex : m_videoTexture; + const auto TEXBOX = getScaledBoxForTextureSize(TEX.m_vSize, viewport); + g_pRenderer->renderTexture(TEXBOX, TEX, data.opacity); + return true; // always request the next compositor frame + } + // ── End video path ──────────────────────────────────────────────────── +#endif + updatePrimaryAsset(); updatePendingAsset(); updateScAsset(); diff --git a/src/renderer/widgets/Background.hpp b/src/renderer/widgets/Background.hpp index d3516c5..f757b3e 100644 --- a/src/renderer/widgets/Background.hpp +++ b/src/renderer/widgets/Background.hpp @@ -6,11 +6,15 @@ #include "../../helpers/Color.hpp" #include "../../core/Timer.hpp" #include "../Framebuffer.hpp" +#ifdef HYPRLOCK_HAS_VIDEO +#include "../VideoBackend.hpp" +#endif #include #include #include #include #include +#include struct SPreloadedAsset; class COutput; @@ -82,4 +86,11 @@ class CBackground : public IWidget { ASP reloadTimer; std::filesystem::file_time_type modificationTime; size_t m_imageRevision = 0; + + // Video playback +#ifdef HYPRLOCK_HAS_VIDEO + UP m_videoBackend; + CTexture m_videoTexture; + std::vector m_uploadBuffer; +#endif };