diff --git a/include/screencast/app/cli.h b/include/screencast/app/cli.h index b2aa6bf..46eb66e 100644 --- a/include/screencast/app/cli.h +++ b/include/screencast/app/cli.h @@ -12,6 +12,7 @@ struct SendCommand { std::string_view peer_address; // optional; empty means auto-discover int bitrate_kbps = 4000; // VBV max bitrate int crf = 22; // constant rate factor (quality) + int fps = 0; // 0 = no cap (capture rate) }; struct ReceiveCommand { diff --git a/include/screencast/app/pipeline.h b/include/screencast/app/pipeline.h index 6a2fa2a..63d31fb 100644 --- a/include/screencast/app/pipeline.h +++ b/include/screencast/app/pipeline.h @@ -27,6 +27,10 @@ struct SenderPipelineConfig { // cannot show. int max_encode_width = 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 { diff --git a/src/app/cli.cpp b/src/app/cli.cpp index 368ed8f..dc53900 100644 --- a/src/app/cli.cpp +++ b/src/app/cli.cpp @@ -8,15 +8,15 @@ namespace sc { namespace { void print_usage() { - std::fputs( - "usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate MAX_KBPS] [--crf 0-51]\n" - " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n" - " screencast --discover [--timeout SECONDS]\n" - " screencast waybar [--toggle] # for waybar widgets\n" - "\n" - "--send without --peer discovers a receiver on the LAN and requires\n" - "that exactly one is found.\n", - stderr); + std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate MAX_KBPS] [--crf " + "0-51] [--fps 1-60]\n" + " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n" + " screencast --discover [--timeout SECONDS]\n" + " screencast waybar [--toggle] # for waybar widgets\n" + "\n" + "--send without --peer discovers a receiver on the LAN and requires\n" + "that exactly one is found.\n", + stderr); } bool parse_int(std::string_view text, int& value) { @@ -113,6 +113,13 @@ std::optional parse_cli(int argc, const char* const argv[]) { print_usage(); 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") { std::string_view value; int port = 0; diff --git a/src/app/main.cpp b/src/app/main.cpp index 886090d..52e31d2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -160,7 +160,7 @@ int run_sender(const sc::SendCommand& command) { 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(&session_result)) { std::cerr << std::format("screencast: {}\n", *error); return 1; diff --git a/src/app/pipelines.cpp b/src/app/pipelines.cpp index af439ae..efe1a77 100644 --- a/src/app/pipelines.cpp +++ b/src/app/pipelines.cpp @@ -37,7 +37,7 @@ std::string receiver_service_name() { class SenderPipeline::Impl { public: - explicit Impl(SenderPipelineConfig config) : config_(std::move(config)) {} + explicit Impl(SenderPipelineConfig config) : config_(std::move(config)), current_crf_(config_.encoder.crf) {} ~Impl() { stop(); @@ -60,15 +60,12 @@ class SenderPipeline::Impl { 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. - // The RTP peer is what a restart needs; the session id identifies it. write_sender_state(SenderState{ .session_id = config_.session_id, .receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port), .bitrate_kbps = config_.encoder.bitrate_kbps, .pid = ::getpid(), .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); if (config_.signaling_server.has_value()) { restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port); @@ -88,10 +85,16 @@ class SenderPipeline::Impl { void request_keyframe() { keyframe_requested_.store(true); + pli_count_.fetch_add(1, std::memory_order_relaxed); } private: 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()) { if (keyframe_requested_.exchange(false) && encoder_ != nullptr) { encoder_->request_keyframe(); @@ -106,6 +109,17 @@ class SenderPipeline::Impl { 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); if (is_codec_error(encoded_result)) { std::cerr << std::format("screencast: encode failed: {}\n", codec_error(encoded_result).message); @@ -116,18 +130,58 @@ class SenderPipeline::Impl { (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(now - last_pli_check).count(); + const auto pli_per_second = static_cast(pli_delta) / static_cast(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) { EncoderConfig config = config_.encoder; config.width = frame.width; config.height = frame.height; + config.crf = current_crf_; // Downscale to the receiver's display when the capture is larger, // preserving aspect ratio and rounding to even values (YUV420P - // requires even dimensions for the chroma planes). This avoids - // encoding pixels the display cannot show. + // requires even dimensions for the chroma planes). if (config_.max_encode_width > 0 && config_.max_encode_height > 0 && (frame.width > config_.max_encode_width || frame.height > config_.max_encode_height)) { const double scale = std::min(static_cast(config_.max_encode_width) / frame.width, @@ -151,12 +205,14 @@ class SenderPipeline::Impl { } SenderPipelineConfig config_; + int current_crf_; std::unique_ptr capture_; std::unique_ptr encoder_; H264Packetizer packetizer_; std::unique_ptr transport_ = RtpTransportFactory::create(); std::jthread run_thread_; std::atomic keyframe_requested_{false}; + std::atomic pli_count_{0}; }; void SenderPipeline::request_keyframe() { diff --git a/src/app/sender_session.cpp b/src/app/sender_session.cpp index 25005bf..2820688 100644 --- a/src/app/sender_session.cpp +++ b/src/app/sender_session.cpp @@ -37,7 +37,7 @@ bool is_private_ipv4(std::string_view host) { } // namespace std::variant -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(); if (is_network_error(channel_result)) { 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.encoder.bitrate_kbps = bitrate_kbps; config.encoder.crf = crf; + config.max_frame_rate = max_fps; config.session_id = session_id; config.max_encode_width = answer.display_width; config.max_encode_height = answer.display_height; diff --git a/src/app/sender_session.h b/src/app/sender_session.h index dc87873..c117acb 100644 --- a/src/app/sender_session.h +++ b/src/app/sender_session.h @@ -42,7 +42,7 @@ class SenderSession { // pipeline — which includes the portal's interactive source picker. // Returns an error message on failure. static std::variant - 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(); diff --git a/src/codec/ffmpeg_encoder.cpp b/src/codec/ffmpeg_encoder.cpp index dc40bcd..84cb4fb 100644 --- a/src/codec/ffmpeg_encoder.cpp +++ b/src/codec/ffmpeg_encoder.cpp @@ -419,7 +419,12 @@ CodecResult> EncoderFactory::create(const EncoderConfig // peak so bursts cannot overflow the receiver's UDP buffers. ctx->global_quality = static_cast(config.crf); ctx->rc_max_rate = static_cast(config.bitrate_kbps) * 1000; - ctx->rc_buffer_size = static_cast(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(ctx->rc_max_rate / config.frame_rate_num); // A long GOP saves the keyframe overhead for screen content (which // changes incrementally); PLI feedback recovers from loss within one diff --git a/src/gui/gui.cpp b/src/gui/gui.cpp index cf06c26..878e43b 100644 --- a/src/gui/gui.cpp +++ b/src/gui/gui.cpp @@ -291,7 +291,7 @@ class SenderWindow : public Gtk::ApplicationWindow { status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name)); 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(&result)) { Glib::signal_idle().connect_once([this, message = *error] { start_button_->set_label("Start");