background: add video playback support via FFmpeg

Extend the background widget to natively decode and display video
files (mp4, mkv, webm, avi, mov, gif, and more) using FFmpeg, with
no external tools required.

- Detection is extension-based; existing image paths are unaffected
- A background decode thread paces frames to their PTS timestamps
  and publishes them to the render thread via an O(1) mutex-swap,
  avoiding any memcpy per frame
- sws_getCachedContext handles codecs that only report their pixel
  format after the first decoded frame
- Videos loop seamlessly via av_seek_frame at EOF
- blur_passes works on video frames the same as on images
- No new config keys: path = /path/to/video.mp4 is sufficient
This commit is contained in:
mcgi5sr2 2026-03-02 16:37:41 +00:00
parent 6d955e33db
commit 0345d4ccf8
2 changed files with 295 additions and 3 deletions

View file

@ -7,9 +7,11 @@
#include "../../helpers/MiscFunctions.hpp"
#include "../../core/AnimationManager.hpp"
#include "../../config/ConfigManager.hpp"
#include <algorithm>
#include <chrono>
#include <hyprlang.hpp>
#include <filesystem>
#include <unordered_set>
#include <GLES3/gl32.h>
CBackground::CBackground() {
@ -40,6 +42,173 @@ static std::string runAndGetPath(const std::string& reloadCommand) {
return path;
}
// ── Video support ─────────────────────────────────────────────────────────────
SVideoState::~SVideoState() {
running = false;
if (decodeThread.joinable())
decodeThread.join();
if (swsCtx) sws_freeContext(swsCtx);
if (codecCtx) avcodec_free_context(&codecCtx);
if (formatCtx) avformat_close_input(&formatCtx);
}
bool CBackground::isVideoFile(const std::string& path) {
static const std::unordered_set<std::string> 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 CBackground::openVideo(const std::string& path) {
auto& v = *m_video;
if (avformat_open_input(&v.formatCtx, path.c_str(), nullptr, nullptr) < 0) {
Debug::log(ERR, "CBackground: avformat_open_input failed for {}", path);
return false;
}
if (avformat_find_stream_info(v.formatCtx, nullptr) < 0) {
Debug::log(ERR, "CBackground: avformat_find_stream_info failed for {}", path);
return false;
}
const AVCodec* codec = nullptr;
v.streamIdx = av_find_best_stream(v.formatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0);
if (v.streamIdx < 0 || !codec) {
Debug::log(ERR, "CBackground: no video stream found in {}", path);
return false;
}
v.codecCtx = avcodec_alloc_context3(codec);
if (!v.codecCtx) {
Debug::log(ERR, "CBackground: avcodec_alloc_context3 failed");
return false;
}
avcodec_parameters_to_context(v.codecCtx, v.formatCtx->streams[v.streamIdx]->codecpar);
if (avcodec_open2(v.codecCtx, codec, nullptr) < 0) {
Debug::log(ERR, "CBackground: avcodec_open2 failed for {}", path);
return false;
}
v.frameW = v.codecCtx->width;
v.frameH = v.codecCtx->height;
v.timeBase = av_q2d(v.formatCtx->streams[v.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.
v.frameData.resize(4 * v.frameW * v.frameH);
Debug::log(LOG, "CBackground: opened video {} ({}x{}, timebase={:.6f})",
path, v.frameW, v.frameH, v.timeBase);
return true;
}
void CBackground::startVideoThread() {
auto& v = *m_video;
v.running = true;
v.startTime = std::chrono::steady_clock::now();
v.decodeThread = std::thread([&v]() {
AVPacket* pkt = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
std::vector<uint8_t> tmpBuf(4 * v.frameW * v.frameH);
while (v.running) {
int ret = av_read_frame(v.formatCtx, pkt);
if (ret == AVERROR_EOF) {
// Flush decoder's internal buffer
avcodec_send_packet(v.codecCtx, nullptr);
while (avcodec_receive_frame(v.codecCtx, frame) == 0)
av_frame_unref(frame);
// Loop: seek back to beginning
av_seek_frame(v.formatCtx, v.streamIdx, 0, AVSEEK_FLAG_BACKWARD);
avcodec_flush_buffers(v.codecCtx);
v.startTime = std::chrono::steady_clock::now();
continue;
}
if (ret < 0)
break; // unrecoverable error
if (pkt->stream_index != v.streamIdx) {
av_packet_unref(pkt);
continue;
}
if (avcodec_send_packet(v.codecCtx, pkt) < 0) {
av_packet_unref(pkt);
continue;
}
av_packet_unref(pkt);
while (avcodec_receive_frame(v.codecCtx, frame) == 0) {
if (!v.running)
break;
// Lazily create / update SwsContext to match the frame's actual
// pixel format (some codecs only report it after the first frame).
v.swsCtx = sws_getCachedContext(v.swsCtx,
frame->width, frame->height, (AVPixelFormat)frame->format,
v.frameW, v.frameH, AV_PIX_FMT_RGBA,
SWS_BILINEAR, nullptr, nullptr, nullptr);
if (!v.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 * v.frameW, 0, 0, 0};
sws_scale(v.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<std::mutex> lock(v.frameMutex);
std::swap(v.frameData, tmpBuf);
v.hasNewFrame = true;
}
// tmpBuf now holds old frame data and will be overwritten next iteration
// PTS-based frame pacing
if (frame->pts != AV_NOPTS_VALUE) {
double pts_sec = frame->pts * v.timeBase;
auto target = v.startTime +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(pts_sec));
auto now = std::chrono::steady_clock::now();
// Safety cap: never sleep > 5 s (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);
});
}
void CBackground::stopVideo() {
m_video.reset(); // ~SVideoState(): sets running=false, joins thread, frees ffmpeg
m_videoTexture.destroyTexture();
m_uploadBuffer.clear();
m_isVideo = false;
}
// ── End video support ─────────────────────────────────────────────────────────
void CBackground::configure(const std::unordered_map<std::string, std::any>& props, const SP<COutput>& pOutput) {
reset();
@ -86,10 +255,29 @@ void CBackground::configure(const std::unordered_map<std::string, std::any>& pro
Debug::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()) {
if (isVideoFile(path)) {
m_isVideo = true;
m_video = makeUnique<SVideoState>();
if (!openVideo(path)) {
Debug::log(ERR, "CBackground: failed to open '{}' as video, falling back to image", path);
m_video.reset();
m_isVideo = false;
resourceID = g_asyncResourceManager->requestImage(path, m_imageRevision, nullptr);
} else {
// Pre-size the upload buffer so it's never empty when the main
// thread swaps it with frameData. An empty vector has data()==null
// which would propagate back to tmpBuf in the decode thread and
// cause "bad dst image pointers" on the third sws_scale call.
m_uploadBuffer.resize(4 * m_video->frameW * m_video->frameH);
startVideoThread();
}
} else {
resourceID = g_asyncResourceManager->requestImage(path, m_imageRevision, nullptr);
}
}
if (!reloadCommand.empty() && reloadTime > -1) {
if (!reloadCommand.empty() && reloadTime > -1 && !m_isVideo) {
try {
if (!isScreenshot)
modificationTime = std::filesystem::last_write_time(absolutePath(path, ""));
@ -100,6 +288,9 @@ void CBackground::configure(const std::unordered_map<std::string, std::any>& pro
}
void CBackground::reset() {
if (m_isVideo)
stopVideo();
if (reloadTimer) {
reloadTimer->cancel();
reloadTimer.reset();
@ -225,6 +416,61 @@ void CBackground::renderToFB(const CTexture& tex, CFramebuffer& fb, int passes,
}
bool CBackground::draw(const SRenderData& data) {
// ── Video background fast path ────────────────────────────────────────
if (m_isVideo && m_video) {
// Grab the latest decoded frame from the decode thread (O(1) swap)
{
std::lock_guard<std::mutex> lock(m_video->frameMutex);
if (m_video->hasNewFrame) {
std::swap(m_uploadBuffer, m_video->frameData);
m_video->hasNewFrame = false;
}
}
// Upload frame to GL texture
if (!m_uploadBuffer.empty()) {
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,
m_video->frameW, m_video->frameH, 0,
GL_RGBA, GL_UNSIGNED_BYTE, m_uploadBuffer.data());
glBindTexture(GL_TEXTURE_2D, 0);
m_videoTexture.m_vSize = {(double)m_video->frameW, (double)m_video->frameH};
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,
m_video->frameW, m_video->frameH,
GL_RGBA, GL_UNSIGNED_BYTE, m_uploadBuffer.data());
glBindTexture(GL_TEXTURE_2D, 0);
}
// Re-render blur FB if requested (GPU-side only, called every new frame)
if (blurPasses > 0)
renderToFB(m_videoTexture, *blurredFB, blurPasses);
}
// Render
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 ────────────────────────────────────────────────────
updatePrimaryAsset();
updatePendingAsset();
updateScAsset();

View file

@ -11,10 +11,44 @@
#include <unordered_map>
#include <any>
#include <filesystem>
#include <thread>
#include <mutex>
#include <atomic>
#include <vector>
#include <chrono>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
}
struct SPreloadedAsset;
class COutput;
struct SVideoState {
AVFormatContext* formatCtx = nullptr;
AVCodecContext* codecCtx = nullptr;
SwsContext* swsCtx = nullptr;
int streamIdx = -1;
int frameW = 0;
int frameH = 0;
double timeBase = 0.0; // seconds per PTS unit
// Frame double-buffer (main thread swaps with its m_uploadBuffer)
std::mutex frameMutex;
std::vector<uint8_t> frameData; // latest RGBA frame
bool hasNewFrame = false;
std::chrono::steady_clock::time_point startTime;
std::thread decodeThread;
std::atomic<bool> running{false};
~SVideoState(); // defined in .cpp so destructor sees complete ffmpeg types
};
class CBackground : public IWidget {
public:
CBackground();
@ -46,6 +80,12 @@ class CBackground : public IWidget {
private:
AWP<CBackground> m_self;
// Video background support
static bool isVideoFile(const std::string& path);
bool openVideo(const std::string& path);
void startVideoThread();
void stopVideo();
// if needed
UP<CFramebuffer> blurredFB;
UP<CFramebuffer> pendingBlurredFB;
@ -82,4 +122,10 @@ class CBackground : public IWidget {
ASP<CTimer> reloadTimer;
std::filesystem::file_time_type modificationTime;
size_t m_imageRevision = 0;
// Video playback state
bool m_isVideo = false;
UP<SVideoState> m_video;
CTexture m_videoTexture;
std::vector<uint8_t> m_uploadBuffer; // O(1) swap target for decoded frames
};