feat(app): adaptive quality, frame rate capping, and tighter VBV

Three changes to make the stream survive constrained links:

Adaptive quality: the sender pipeline now tracks the PLI rate from
the receiver. Every 5 seconds it evaluates: >0.5 PLI/s means the
link is saturated (the receiver is dropping frames), so the CRF
increases by 2 (lower quality, fewer bits) and the encoder restarts
with a keyframe. <0.1 PLI/s means the link is stable, so the CRF
decreases by 1 (better quality) and the encoder probes upward.
Clamped to [user CRF, user CRF + 10] so quality never degrades
below what the link can handle, and never exceeds what the user
asked for. The adaptation is logged to stderr for visibility.

Frame rate capping (--fps N): throttles the capture loop to N
frames per second (0 = no cap; monitor rate). At 15fps instead of
60fps, the bandwidth requirement drops 4x at the same quality
level. Desktop content is still smooth at 15-20fps.

Tighter VBV: one frame period of buffer instead of two. A two-frame
buffer lets a keyframe spike to twice the target rate in one burst,
which overflows any constrained hop (Wi-Fi hotspot, slow switch)
and cascades into PLI storms. One frame period keeps bursts
within what the link can absorb in real time.
This commit is contained in:
2026-09-09 13:00:03 +02:00
parent c78dec139b
commit e82e7853d1
9 changed files with 94 additions and 20 deletions
+1
View File
@@ -12,6 +12,7 @@ struct SendCommand {
std::string_view peer_address; // optional; empty means auto-discover std::string_view peer_address; // optional; empty means auto-discover
int bitrate_kbps = 4000; // VBV max bitrate int bitrate_kbps = 4000; // VBV max bitrate
int crf = 22; // constant rate factor (quality) int crf = 22; // constant rate factor (quality)
int fps = 0; // 0 = no cap (capture rate)
}; };
struct ReceiveCommand { struct ReceiveCommand {
+4
View File
@@ -27,6 +27,10 @@ struct SenderPipelineConfig {
// cannot show. // cannot show.
int max_encode_width = 0; int max_encode_width = 0;
int max_encode_height = 0; int max_encode_height = 0;
// Cap the encoding frame rate (0 = no cap; use the capture rate).
// Lowering the frame rate halves the bandwidth at the same quality
// level — useful on constrained links.
int max_frame_rate = 0;
}; };
struct ReceiverPipelineConfig { struct ReceiverPipelineConfig {
+9 -2
View File
@@ -8,8 +8,8 @@ namespace sc {
namespace { namespace {
void print_usage() { void print_usage() {
std::fputs( std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate MAX_KBPS] [--crf "
"usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate MAX_KBPS] [--crf 0-51]\n" "0-51] [--fps 1-60]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n" " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n"
" screencast --discover [--timeout SECONDS]\n" " screencast --discover [--timeout SECONDS]\n"
" screencast waybar [--toggle] # for waybar widgets\n" " screencast waybar [--toggle] # for waybar widgets\n"
@@ -113,6 +113,13 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
print_usage(); print_usage();
return std::nullopt; return std::nullopt;
} }
} else if (argument == "--fps") {
std::string_view value;
if (!next_argument(argc, argv, index, value) || !parse_int(value, send.fps) || send.fps < 0 ||
send.fps > 60) {
print_usage();
return std::nullopt;
}
} else if (argument == "--port") { } else if (argument == "--port") {
std::string_view value; std::string_view value;
int port = 0; int port = 0;
+1 -1
View File
@@ -160,7 +160,7 @@ int run_sender(const sc::SendCommand& command) {
signaling = sc::Endpoint{hosts.front(), port}; signaling = sc::Endpoint{hosts.front(), port};
} }
auto session_result = sc::SenderSession::start(signaling, command.bitrate_kbps, command.crf, target); auto session_result = sc::SenderSession::start(signaling, command.bitrate_kbps, command.crf, command.fps, target);
if (auto* error = std::get_if<std::string>(&session_result)) { if (auto* error = std::get_if<std::string>(&session_result)) {
std::cerr << std::format("screencast: {}\n", *error); std::cerr << std::format("screencast: {}\n", *error);
return 1; return 1;
+62 -6
View File
@@ -37,7 +37,7 @@ std::string receiver_service_name() {
class SenderPipeline::Impl { class SenderPipeline::Impl {
public: public:
explicit Impl(SenderPipelineConfig config) : config_(std::move(config)) {} explicit Impl(SenderPipelineConfig config) : config_(std::move(config)), current_crf_(config_.encoder.crf) {}
~Impl() { ~Impl() {
stop(); stop();
@@ -60,15 +60,12 @@ class SenderPipeline::Impl {
run_thread_ = std::jthread([this](std::stop_token stop_token) { run(std::move(stop_token)); }); run_thread_ = std::jthread([this](std::stop_token stop_token) { run(std::move(stop_token)); });
// Publish the session for status widgets and one-click restarts. // Publish the session for status widgets and one-click restarts.
// The RTP peer is what a restart needs; the session id identifies it.
write_sender_state(SenderState{ write_sender_state(SenderState{
.session_id = config_.session_id, .session_id = config_.session_id,
.receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port), .receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port),
.bitrate_kbps = config_.encoder.bitrate_kbps, .bitrate_kbps = config_.encoder.bitrate_kbps,
.pid = ::getpid(), .pid = ::getpid(),
.started_epoch_ms = 0}); .started_epoch_ms = 0});
// A restart targets the receiver's signaling endpoint; the RTP
// endpoint is re-negotiated from it.
std::string restart_peer = std::format("{}:5005", config_.peer_rtp_endpoint.address); std::string restart_peer = std::format("{}:5005", config_.peer_rtp_endpoint.address);
if (config_.signaling_server.has_value()) { if (config_.signaling_server.has_value()) {
restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port); restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port);
@@ -88,10 +85,16 @@ class SenderPipeline::Impl {
void request_keyframe() { void request_keyframe() {
keyframe_requested_.store(true); keyframe_requested_.store(true);
pli_count_.fetch_add(1, std::memory_order_relaxed);
} }
private: private:
void run(std::stop_token stop_token) { void run(std::stop_token stop_token) {
auto last_adaptation = std::chrono::steady_clock::now();
auto last_pli_check = last_adaptation;
auto last_pli_count = 0;
auto last_frame_time = last_adaptation;
while (!stop_token.stop_requested()) { while (!stop_token.stop_requested()) {
if (keyframe_requested_.exchange(false) && encoder_ != nullptr) { if (keyframe_requested_.exchange(false) && encoder_ != nullptr) {
encoder_->request_keyframe(); encoder_->request_keyframe();
@@ -106,6 +109,17 @@ class SenderPipeline::Impl {
break; break;
} }
// Frame rate capping: skip frames that arrive faster than the
// configured target. 0 = no cap (use the monitor rate).
if (config_.max_frame_rate > 0) {
const auto now = std::chrono::steady_clock::now();
const auto min_interval = std::chrono::microseconds(1'000'000 / config_.max_frame_rate);
if (now - last_frame_time < min_interval) {
continue;
}
last_frame_time = now;
}
auto encoded_result = encoder_->encode(*frame); auto encoded_result = encoder_->encode(*frame);
if (is_codec_error(encoded_result)) { if (is_codec_error(encoded_result)) {
std::cerr << std::format("screencast: encode failed: {}\n", codec_error(encoded_result).message); std::cerr << std::format("screencast: encode failed: {}\n", codec_error(encoded_result).message);
@@ -116,18 +130,58 @@ class SenderPipeline::Impl {
(void)transport_->send(packet); (void)transport_->send(packet);
} }
} }
// Adaptive quality: evaluate the link every 5 seconds by
// checking how many PLIs the receiver sent. Frequent PLIs
// mean the receiver is dropping frames — the link is
// saturated, so increase the CRF (lower quality, fewer
// bits). When the link is quiet, try lowering the CRF to
// probe for better quality.
const auto now = std::chrono::steady_clock::now();
if (now - last_pli_check >= std::chrono::seconds(5)) {
const auto current_pli = pli_count_.load(std::memory_order_relaxed);
const auto pli_delta = current_pli - last_pli_count;
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(now - last_pli_check).count();
const auto pli_per_second = static_cast<double>(pli_delta) / static_cast<double>(seconds);
last_pli_count = current_pli;
last_pli_check = now;
if (pli_per_second > 0.5 && current_crf_ < config_.encoder.crf + 10) {
// Saturated: degrade quality (higher CRF = fewer bits)
current_crf_ += 2;
std::cerr << std::format(
"screencast: link saturated ({} PLI/s); adapting CRF to {}\n", pli_per_second, current_crf_);
if (!restart_encoder(*frame)) {
break;
} }
} else if (pli_per_second < 0.1 && current_crf_ > config_.encoder.crf) {
// Stable: try better quality (lower CRF = more bits)
current_crf_ -= 1;
std::cerr << std::format("screencast: link stable; probing CRF {}\n", current_crf_);
if (!restart_encoder(*frame)) {
break;
}
}
}
}
}
bool restart_encoder(const CapturedFrame& frame) {
// Create a new encoder with the adjusted CRF; the next encoded
// frame is a keyframe, so the receiver recovers immediately.
encoder_ = nullptr;
return create_encoder(frame);
} }
bool create_encoder(const CapturedFrame& frame) { bool create_encoder(const CapturedFrame& frame) {
EncoderConfig config = config_.encoder; EncoderConfig config = config_.encoder;
config.width = frame.width; config.width = frame.width;
config.height = frame.height; config.height = frame.height;
config.crf = current_crf_;
// Downscale to the receiver's display when the capture is larger, // Downscale to the receiver's display when the capture is larger,
// preserving aspect ratio and rounding to even values (YUV420P // preserving aspect ratio and rounding to even values (YUV420P
// requires even dimensions for the chroma planes). This avoids // requires even dimensions for the chroma planes).
// encoding pixels the display cannot show.
if (config_.max_encode_width > 0 && config_.max_encode_height > 0 && if (config_.max_encode_width > 0 && config_.max_encode_height > 0 &&
(frame.width > config_.max_encode_width || frame.height > config_.max_encode_height)) { (frame.width > config_.max_encode_width || frame.height > config_.max_encode_height)) {
const double scale = std::min(static_cast<double>(config_.max_encode_width) / frame.width, const double scale = std::min(static_cast<double>(config_.max_encode_width) / frame.width,
@@ -151,12 +205,14 @@ class SenderPipeline::Impl {
} }
SenderPipelineConfig config_; SenderPipelineConfig config_;
int current_crf_;
std::unique_ptr<CaptureSession> capture_; std::unique_ptr<CaptureSession> capture_;
std::unique_ptr<Encoder> encoder_; std::unique_ptr<Encoder> encoder_;
H264Packetizer packetizer_; H264Packetizer packetizer_;
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create(); std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
std::jthread run_thread_; std::jthread run_thread_;
std::atomic<bool> keyframe_requested_{false}; std::atomic<bool> keyframe_requested_{false};
std::atomic<int> pli_count_{0};
}; };
void SenderPipeline::request_keyframe() { void SenderPipeline::request_keyframe() {
+2 -1
View File
@@ -37,7 +37,7 @@ bool is_private_ipv4(std::string_view host) {
} // namespace } // namespace
std::variant<SenderSession, std::string> std::variant<SenderSession, std::string>
SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, int crf, CaptureTarget target) { SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, int crf, int max_fps, CaptureTarget target) {
auto channel_result = SignalingFactory::create_client(); auto channel_result = SignalingFactory::create_client();
if (is_network_error(channel_result)) { if (is_network_error(channel_result)) {
return network_error(channel_result).message; return network_error(channel_result).message;
@@ -99,6 +99,7 @@ SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, int c
config.signaling_server = signaling_endpoint; config.signaling_server = signaling_endpoint;
config.encoder.bitrate_kbps = bitrate_kbps; config.encoder.bitrate_kbps = bitrate_kbps;
config.encoder.crf = crf; config.encoder.crf = crf;
config.max_frame_rate = max_fps;
config.session_id = session_id; config.session_id = session_id;
config.max_encode_width = answer.display_width; config.max_encode_width = answer.display_width;
config.max_encode_height = answer.display_height; config.max_encode_height = answer.display_height;
+1 -1
View File
@@ -42,7 +42,7 @@ class SenderSession {
// pipeline — which includes the portal's interactive source picker. // pipeline — which includes the portal's interactive source picker.
// Returns an error message on failure. // Returns an error message on failure.
static std::variant<SenderSession, std::string> static std::variant<SenderSession, std::string>
start(const Endpoint& signaling_endpoint, int bitrate_kbps, int crf, CaptureTarget target); start(const Endpoint& signaling_endpoint, int bitrate_kbps, int crf, int max_fps, CaptureTarget target);
SenderSession() = default; SenderSession() = default;
~SenderSession(); ~SenderSession();
+6 -1
View File
@@ -419,7 +419,12 @@ CodecResult<std::unique_ptr<Encoder>> EncoderFactory::create(const EncoderConfig
// peak so bursts cannot overflow the receiver's UDP buffers. // peak so bursts cannot overflow the receiver's UDP buffers.
ctx->global_quality = static_cast<int>(config.crf); ctx->global_quality = static_cast<int>(config.crf);
ctx->rc_max_rate = static_cast<int64_t>(config.bitrate_kbps) * 1000; ctx->rc_max_rate = static_cast<int64_t>(config.bitrate_kbps) * 1000;
ctx->rc_buffer_size = static_cast<int>(ctx->rc_max_rate * 2 / config.frame_rate_num); // VBV: one frame period of budget keeps bursts tight — a two-frame
// buffer lets a keyframe spike beyond what a constrained link can
// absorb in real time, causing packet loss that cascades into PLI
// storms. The tighter buffer trades a small quality dip on keyframes
// for much better behavior on slow paths.
ctx->rc_buffer_size = static_cast<int>(ctx->rc_max_rate / config.frame_rate_num);
// A long GOP saves the keyframe overhead for screen content (which // A long GOP saves the keyframe overhead for screen content (which
// changes incrementally); PLI feedback recovers from loss within one // changes incrementally); PLI feedback recovers from loss within one
+1 -1
View File
@@ -291,7 +291,7 @@ class SenderWindow : public Gtk::ApplicationWindow {
status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name)); status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name));
worker_ = std::jthread([this, signaling, bitrate, crf](std::stop_token) { worker_ = std::jthread([this, signaling, bitrate, crf](std::stop_token) {
auto result = sc::SenderSession::start(signaling, bitrate, crf, sc::CaptureTargetWholeScreen{}); auto result = sc::SenderSession::start(signaling, bitrate, crf, 0, sc::CaptureTargetWholeScreen{});
if (auto* error = std::get_if<std::string>(&result)) { if (auto* error = std::get_if<std::string>(&result)) {
Glib::signal_idle().connect_once([this, message = *error] { Glib::signal_idle().connect_once([this, message = *error] {
start_button_->set_label("Start"); start_button_->set_label("Start");