From 7ae9b3e635fb137e7d658bad1b440f80072d9d5c 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 ce1387092da..4821e924d42 100644 --- a/src/platform/linux/cuda.cpp +++ b/src/platform/linux/cuda.cpp @@ -993,6 +993,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 470e27e52ca..8b3a03287b4 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -874,6 +874,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 ba53d1629f1..ca641885036 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -55,6 +55,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 18eb4cf75a9..754030eba28 100644 --- a/src/platform/linux/x11grab.cpp +++ b/src/platform/linux/x11grab.cpp @@ -500,6 +500,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 8f712a5b85469a1371eee32b04269c4fed185790 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 4821e924d42..10b34425f62 100644 --- a/src/platform/linux/cuda.cpp +++ b/src/platform/linux/cuda.cpp @@ -992,12 +992,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 8b3a03287b4..5215d94251c 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -873,12 +873,7 @@ namespace platf { * @return 0 on success; nonzero or negative platform status on failure. */ 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 ff25d3819a1..e8687422569 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -806,14 +806,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 ca641885036..3258ec04e90 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -54,12 +54,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 754030eba28..501763ae631 100644 --- a/src/platform/linux/x11grab.cpp +++ b/src/platform/linux/x11grab.cpp @@ -499,12 +499,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 0a49741c01e..798fec25fa3 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" @@ -640,4 +643,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 00acec7e41e107253ca3e334e487eb9ab9f0112a 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 2514f1f2415..f464573b194 100644 --- a/src/nvenc/nvenc_base.cpp +++ b/src/nvenc/nvenc_base.cpp @@ -226,13 +226,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 e8687422569..978d5aaf0b3 100644 --- a/src/platform/linux/pipewire.cpp +++ b/src/platform/linux/pipewire.cpp @@ -807,11 +807,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 3258ec04e90..65020d6262f 100644 --- a/src/platform/linux/wlgrab.cpp +++ b/src/platform/linux/wlgrab.cpp @@ -47,15 +47,12 @@ namespace wl { */ 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 c576ae0c93a..071efc355c3 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -728,7 +728,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 971e38308b1..8e440de39f2 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -1954,13 +1954,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 798fec25fa3..d94c9ec4c58 100644 --- a/src/video.h +++ b/src/video.h @@ -644,15 +644,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 + ) +);