From ad9f25b5e813fd21fbd20c0d07e180f1c7c39945 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Thu, 11 Jun 2026 09:57:14 +0200 Subject: [PATCH 1/3] feat(linux): pace capture at exact fractional NTSC framerates When a client requests a fractional refresh rate via x-nv-video[0].clientRefreshRateX100 (e.g. 11988 for an Xbox whose display pipeline runs at 120/1.001 Hz), the encoder time base is already set to the exact rational since #4019, but the Linux capture loops still paced at the integer maxFPS. The resulting 0.1% delivery surplus accumulates one extra frame every ~8 s, which the client pacer keeps correcting (periodic frame queue oscillation / micro-judder). Apply the same exact-rational handling to the kmsgrab, wlgrab, x11grab and CUDA capture pacing, mirroring the Windows implementation from #4019. Tested on an AMD (RDNA4) + KDE Wayland host with KMS capture against a moonlight-xbox client at 4K 119.88: the standing frame-queue oscillation on the client disappears. --- src/platform/linux/cuda.cpp | 5 +++++ src/platform/linux/kmsgrab.cpp | 5 +++++ src/platform/linux/wlgrab.cpp | 5 +++++ src/platform/linux/x11grab.cpp | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/src/platform/linux/cuda.cpp b/src/platform/linux/cuda.cpp index 492adbd1238..2eddc5cc6c3 100644 --- a/src/platform/linux/cuda.cpp +++ b/src/platform/linux/cuda.cpp @@ -806,6 +806,11 @@ namespace cuda { } delay = std::chrono::nanoseconds {1s} / config.framerate; + if (config.framerateX100 > 0) { + // Use exactly the requested rate if the client sent an X100 value + AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); + delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; + } capture_params = NVFBC_CREATE_CAPTURE_SESSION_PARAMS {NVFBC_CREATE_CAPTURE_SESSION_PARAMS_VER}; diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 11571c7c361..9b2a5776697 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -610,6 +610,11 @@ namespace platf { int init(const std::string &display_name, const ::video::config_t &config) { delay = std::chrono::nanoseconds {1s} / config.framerate; + if (config.framerateX100 > 0) { + // Use exactly the requested rate if the client sent an X100 value + AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); + delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; + } int monitor_index = util::from_view(display_name); int monitor = 0; diff --git a/src/platform/linux/wlgrab.cpp b/src/platform/linux/wlgrab.cpp index f99e81c65b4..65f6dc137b8 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -38,6 +38,11 @@ namespace wl { BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << fps_strict.num << "/" << fps_strict.den << ", approx. " << av_q2d(fps_strict) << " fps]"; } else { delay = std::chrono::nanoseconds {1s} / config.framerate; + if (config.framerateX100 > 0) { + // Use exactly the requested rate if the client sent an X100 value + AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); + delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; + } BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << config.framerate << "fps]"; } diff --git a/src/platform/linux/x11grab.cpp b/src/platform/linux/x11grab.cpp index 6eef5a81abc..8c81c2d4746 100644 --- a/src/platform/linux/x11grab.cpp +++ b/src/platform/linux/x11grab.cpp @@ -392,6 +392,11 @@ namespace platf { } delay = std::chrono::nanoseconds {1s} / config.framerate; + if (config.framerateX100 > 0) { + // Use exactly the requested rate if the client sent an X100 value + AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); + delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; + } xwindow = DefaultRootWindow(xdisplay.get()); From 9e32f0fc46b505373784d68195ab3e01981391f6 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Thu, 11 Jun 2026 15:23:20 +0200 Subject: [PATCH 2/3] refactor(linux): deduplicate capture frame interval calculation Extract video::capture_frame_interval() and use it in all Linux capture pacers, including the PipeWire path which carried its own copy of the same math. --- src/platform/linux/cuda.cpp | 7 +------ src/platform/linux/kmsgrab.cpp | 7 +------ src/platform/linux/pipewire.cpp | 5 +---- src/platform/linux/wlgrab.cpp | 7 +------ src/platform/linux/x11grab.cpp | 7 +------ src/video.h | 15 +++++++++++++++ 6 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/platform/linux/cuda.cpp b/src/platform/linux/cuda.cpp index 2eddc5cc6c3..205619bc614 100644 --- a/src/platform/linux/cuda.cpp +++ b/src/platform/linux/cuda.cpp @@ -805,12 +805,7 @@ namespace cuda { } } - delay = std::chrono::nanoseconds {1s} / config.framerate; - if (config.framerateX100 > 0) { - // Use exactly the requested rate if the client sent an X100 value - AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; - } + delay = ::video::capture_frame_interval(config); capture_params = NVFBC_CREATE_CAPTURE_SESSION_PARAMS {NVFBC_CREATE_CAPTURE_SESSION_PARAMS_VER}; diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 9b2a5776697..a91c17356ea 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -609,12 +609,7 @@ namespace platf { } int init(const std::string &display_name, const ::video::config_t &config) { - delay = std::chrono::nanoseconds {1s} / config.framerate; - if (config.framerateX100 > 0) { - // Use exactly the requested rate if the client sent an X100 value - AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; - } + delay = ::video::capture_frame_interval(config); int monitor_index = util::from_view(display_name); int monitor = 0; diff --git a/src/platform/linux/pipewire.cpp b/src/platform/linux/pipewire.cpp index d7a20940409..c3e7440deff 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -675,14 +675,11 @@ namespace pipewire { int init(platf::mem_type_e hwdevice_type, const std::string &display_name, const ::video::config_t &config) { // calculate frame interval we should capture at framerate = config.framerate; + delay = ::video::capture_frame_interval(config); if (config.framerateX100 > 0) { AVRational fps_strict = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds( - (static_cast(fps_strict.den) * 1'000'000'000LL) / fps_strict.num - ); BOOST_LOG(info) << "[pipewire] Requested frame rate [" << fps_strict.num << "/" << fps_strict.den << ", approx. " << av_q2d(fps_strict) << " fps]"; } else { - delay = std::chrono::nanoseconds {1s} / framerate; BOOST_LOG(info) << "[pipewire] Requested frame rate [" << framerate << "fps]"; } mem_type = hwdevice_type; diff --git a/src/platform/linux/wlgrab.cpp b/src/platform/linux/wlgrab.cpp index 65f6dc137b8..38c11645f3d 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -37,12 +37,7 @@ namespace wl { ); BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << fps_strict.num << "/" << fps_strict.den << ", approx. " << av_q2d(fps_strict) << " fps]"; } else { - delay = std::chrono::nanoseconds {1s} / config.framerate; - if (config.framerateX100 > 0) { - // Use exactly the requested rate if the client sent an X100 value - AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; - } + delay = ::video::capture_frame_interval(config); BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << config.framerate << "fps]"; } diff --git a/src/platform/linux/x11grab.cpp b/src/platform/linux/x11grab.cpp index 8c81c2d4746..1a42a07a9b0 100644 --- a/src/platform/linux/x11grab.cpp +++ b/src/platform/linux/x11grab.cpp @@ -391,12 +391,7 @@ namespace platf { return -1; } - delay = std::chrono::nanoseconds {1s} / config.framerate; - if (config.framerateX100 > 0) { - // Use exactly the requested rate if the client sent an X100 value - AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds {std::chrono::nanoseconds {1s}.count() * fps.den / fps.num}; - } + delay = ::video::capture_frame_interval(config); xwindow = DefaultRootWindow(xdisplay.get()); diff --git a/src/video.h b/src/video.h index 5b474d3f101..d31cc6d2492 100644 --- a/src/video.h +++ b/src/video.h @@ -4,6 +4,9 @@ */ #pragma once +// standard includes +#include + // local includes #include "input.h" #include "platform/common.h" @@ -386,4 +389,16 @@ namespace video { return av_d2q((double) framerateX100 / 100.0f, 1 << 26); } } + + /** + * @brief Capture frame interval for the requested framerate. + * Uses the exact fractional rate when the client provided an X100 value. + */ + inline std::chrono::nanoseconds capture_frame_interval(const config_t &config) { + if (config.framerateX100 > 0) { + AVRational fps = framerateX100_to_rational(config.framerateX100); + return std::chrono::nanoseconds {(static_cast(fps.den) * 1'000'000'000LL) / fps.num}; + } + return std::chrono::nanoseconds {std::chrono::seconds {1}} / config.framerate; + } } // namespace video From dd923f49445fedadca8a79fed1964529a888535e Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Thu, 11 Jun 2026 16:53:57 +0200 Subject: [PATCH 3/3] refactor(video): derive framerate rationals via shared helper Address review feedback: extract video::framerate_to_rational() as the single place implementing the "exact rational when framerateX100 is set, integer framerate otherwise" pattern, and rebuild capture_frame_interval() on top of it. Migrate the call sites that re-implemented the pattern: avcodec and NVENC encoder setup become branchless, the PipeWire and wlroots capture pacers no longer re-derive the rational for logging, and the Windows strict frame rate reuses the helper inside its sentinel branch (the {0,0} sentinel must stay to keep the refresh-rate matching heuristic). Add unit tests for the new helper and the capture frame interval, including the integer fallback. Co-Authored-By: Claude Fable 5 --- src/nvenc/nvenc_base.cpp | 10 ++---- src/platform/linux/pipewire.cpp | 8 ++--- src/platform/linux/wlgrab.cpp | 13 +++----- src/platform/windows/display_base.cpp | 2 +- src/video.cpp | 10 ++---- src/video.h | 19 ++++++++--- tests/unit/test_video.cpp | 45 +++++++++++++++++++++++++++ 7 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/nvenc/nvenc_base.cpp b/src/nvenc/nvenc_base.cpp index 13b2fa03a14..c2db4b9860c 100644 --- a/src/nvenc/nvenc_base.cpp +++ b/src/nvenc/nvenc_base.cpp @@ -222,13 +222,9 @@ namespace nvenc { init_params.darWidth = encoder_params.width; init_params.encodeHeight = encoder_params.height; init_params.darHeight = encoder_params.height; - init_params.frameRateNum = client_config.framerate; - init_params.frameRateDen = 1; - if (client_config.framerateX100 > 0) { - AVRational fps = video::framerateX100_to_rational(client_config.framerateX100); - init_params.frameRateNum = fps.num; - init_params.frameRateDen = fps.den; - } + const AVRational fps = video::framerate_to_rational(client_config); + init_params.frameRateNum = fps.num; + init_params.frameRateDen = fps.den; if (client_config.videoFormat > 0 && get_encoder_cap(NV_ENC_CAPS_NUM_ENCODER_ENGINES) > 1) { // SFE supports HEVC/AV1 if you have more than 1 nvenc block diff --git a/src/platform/linux/pipewire.cpp b/src/platform/linux/pipewire.cpp index c3e7440deff..7a7f5db13a8 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -676,11 +676,11 @@ namespace pipewire { // calculate frame interval we should capture at framerate = config.framerate; delay = ::video::capture_frame_interval(config); - if (config.framerateX100 > 0) { - AVRational fps_strict = ::video::framerateX100_to_rational(config.framerateX100); - BOOST_LOG(info) << "[pipewire] Requested frame rate [" << fps_strict.num << "/" << fps_strict.den << ", approx. " << av_q2d(fps_strict) << " fps]"; + const AVRational fps = ::video::framerate_to_rational(config); + if (fps.den != 1) { + BOOST_LOG(info) << "[pipewire] Requested frame rate [" << fps.num << "/" << fps.den << ", approx. " << av_q2d(fps) << " fps]"; } else { - BOOST_LOG(info) << "[pipewire] Requested frame rate [" << framerate << "fps]"; + BOOST_LOG(info) << "[pipewire] Requested frame rate [" << fps.num << "fps]"; } mem_type = hwdevice_type; diff --git a/src/platform/linux/wlgrab.cpp b/src/platform/linux/wlgrab.cpp index 38c11645f3d..dd84efb6d1e 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -30,15 +30,12 @@ namespace wl { public: int init(platf::mem_type_e hwdevice_type, const std::string &display_name, const ::video::config_t &config) { // calculate frame interval we should capture at - if (config.framerateX100 > 0) { - AVRational fps_strict = ::video::framerateX100_to_rational(config.framerateX100); - delay = std::chrono::nanoseconds( - (static_cast(fps_strict.den) * 1'000'000'000LL) / fps_strict.num - ); - BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << fps_strict.num << "/" << fps_strict.den << ", approx. " << av_q2d(fps_strict) << " fps]"; + delay = ::video::capture_frame_interval(config); + const AVRational fps = ::video::framerate_to_rational(config); + if (fps.den != 1) { + BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << fps.num << "/" << fps.den << ", approx. " << av_q2d(fps) << " fps]"; } else { - delay = ::video::capture_frame_interval(config); - BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << config.framerate << "fps]"; + BOOST_LOG(info) << "[wlgrab] Requested frame rate [" << fps.num << "fps]"; } mem_type = hwdevice_type; diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp index e42bb8efa4b..7b7b3c05ef8 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -720,7 +720,7 @@ namespace platf::dxgi { client_frame_rate = config.framerate; client_frame_rate_strict = {0, 0}; if (config.framerateX100 > 0) { - AVRational fps = ::video::framerateX100_to_rational(config.framerateX100); + const AVRational fps = ::video::framerate_to_rational(config); client_frame_rate_strict = DXGI_RATIONAL {static_cast(fps.num), static_cast(fps.den)}; } diff --git a/src/video.cpp b/src/video.cpp index 0900d999de6..e83abd575c2 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -1678,13 +1678,9 @@ namespace video { ctx.reset(avcodec_alloc_context3(codec)); ctx->width = config.width; ctx->height = config.height; - ctx->time_base = AVRational {1, config.framerate}; - ctx->framerate = AVRational {config.framerate, 1}; - if (config.framerateX100 > 0) { - AVRational fps = video::framerateX100_to_rational(config.framerateX100); - ctx->framerate = fps; - ctx->time_base = AVRational {fps.den, fps.num}; - } + const AVRational fps = video::framerate_to_rational(config); + ctx->framerate = fps; + ctx->time_base = AVRational {fps.den, fps.num}; switch (config.videoFormat) { case 0: diff --git a/src/video.h b/src/video.h index d31cc6d2492..8ba288c2eb6 100644 --- a/src/video.h +++ b/src/video.h @@ -390,15 +390,24 @@ namespace video { } } + /** + * @brief Requested framerate as an exact rational. + * Uses the exact fractional rate when the client provided an X100 value, + * otherwise the integer framerate over 1. + */ + inline AVRational framerate_to_rational(const config_t &config) { + if (config.framerateX100 > 0) { + return framerateX100_to_rational(config.framerateX100); + } + return AVRational {config.framerate, 1}; + } + /** * @brief Capture frame interval for the requested framerate. * Uses the exact fractional rate when the client provided an X100 value. */ inline std::chrono::nanoseconds capture_frame_interval(const config_t &config) { - if (config.framerateX100 > 0) { - AVRational fps = framerateX100_to_rational(config.framerateX100); - return std::chrono::nanoseconds {(static_cast(fps.den) * 1'000'000'000LL) / fps.num}; - } - return std::chrono::nanoseconds {std::chrono::seconds {1}} / config.framerate; + const AVRational fps = framerate_to_rational(config); + return std::chrono::nanoseconds {(static_cast(fps.den) * 1'000'000'000LL) / fps.num}; } } // namespace video diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index ed578f7d8db..e71fb1d8f6d 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -76,3 +76,48 @@ INSTANTIATE_TEST_SUITE_P( std::make_tuple(9498, AVRational {4749, 50}) // from my LG 27GN950 ) ); + +struct FramerateToRationalTest: testing::TestWithParam> {}; + +TEST_P(FramerateToRationalTest, Run) { + const auto &[framerate, framerateX100, expected] = GetParam(); + video::config_t config {}; + config.framerate = framerate; + config.framerateX100 = framerateX100; + auto res = video::framerate_to_rational(config); + ASSERT_EQ(0, av_cmp_q(res, expected)) << "expected " + << expected.num << "/" << expected.den + << ", got " + << res.num << "/" << res.den; +} + +INSTANTIATE_TEST_SUITE_P( + FramerateToRationalTests, + FramerateToRationalTest, + testing::Values( + std::make_tuple(60, 0, AVRational {60, 1}), // no X100 value, fall back to integer framerate + std::make_tuple(60, 5994, AVRational {60000, 1001}), + std::make_tuple(120, 11988, AVRational {120000, 1001}), + std::make_tuple(24, 2398, AVRational {24000, 1001}) + ) +); + +struct CaptureFrameIntervalTest: testing::TestWithParam> {}; + +TEST_P(CaptureFrameIntervalTest, Run) { + const auto &[framerate, framerateX100, expected] = GetParam(); + video::config_t config {}; + config.framerate = framerate; + config.framerateX100 = framerateX100; + ASSERT_EQ(expected, video::capture_frame_interval(config)); +} + +INSTANTIATE_TEST_SUITE_P( + CaptureFrameIntervalTests, + CaptureFrameIntervalTest, + testing::Values( + std::make_tuple(60, 0, std::chrono::nanoseconds {16666666}), + std::make_tuple(60, 5994, std::chrono::nanoseconds {16683333}), // 1e9 * 1001 / 60000 + std::make_tuple(120, 11988, std::chrono::nanoseconds {8341666}) // 1e9 * 1001 / 120000 + ) +);