Compare commits
2 Commits
36d086af4e
...
e82e7853d1
| Author | SHA1 | Date | |
|---|---|---|---|
| e82e7853d1 | |||
| c78dec139b |
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+16
-9
@@ -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<Command> 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;
|
||||
|
||||
+1
-1
@@ -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<std::string>(&session_result)) {
|
||||
std::cerr << std::format("screencast: {}\n", *error);
|
||||
return 1;
|
||||
|
||||
+62
-6
@@ -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<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) {
|
||||
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<double>(config_.max_encode_width) / frame.width,
|
||||
@@ -151,12 +205,14 @@ class SenderPipeline::Impl {
|
||||
}
|
||||
|
||||
SenderPipelineConfig config_;
|
||||
int current_crf_;
|
||||
std::unique_ptr<CaptureSession> capture_;
|
||||
std::unique_ptr<Encoder> encoder_;
|
||||
H264Packetizer packetizer_;
|
||||
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
|
||||
std::jthread run_thread_;
|
||||
std::atomic<bool> keyframe_requested_{false};
|
||||
std::atomic<int> pli_count_{0};
|
||||
};
|
||||
|
||||
void SenderPipeline::request_keyframe() {
|
||||
|
||||
@@ -37,7 +37,7 @@ bool is_private_ipv4(std::string_view host) {
|
||||
} // namespace
|
||||
|
||||
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();
|
||||
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;
|
||||
|
||||
@@ -42,7 +42,7 @@ class SenderSession {
|
||||
// pipeline — which includes the portal's interactive source picker.
|
||||
// Returns an error message on failure.
|
||||
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();
|
||||
|
||||
@@ -419,7 +419,12 @@ CodecResult<std::unique_ptr<Encoder>> EncoderFactory::create(const EncoderConfig
|
||||
// peak so bursts cannot overflow the receiver's UDP buffers.
|
||||
ctx->global_quality = static_cast<int>(config.crf);
|
||||
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
|
||||
// changes incrementally); PLI feedback recovers from loss within one
|
||||
|
||||
+81
-17
@@ -1,6 +1,7 @@
|
||||
// GTK4 sender panel: discover receivers on the LAN, pick one, set the
|
||||
// bitrate, and start/stop streaming. The blocking parts (portal source
|
||||
// picker, negotiation) run on worker threads so the UI stays responsive.
|
||||
// GTK4 sender panel: discover receivers on the LAN, pick one, choose a
|
||||
// quality preset, and start/stop streaming. The blocking parts (portal
|
||||
// source picker, negotiation) run on worker threads so the UI stays
|
||||
// responsive.
|
||||
|
||||
#include "screencast/network/discovery.h"
|
||||
|
||||
@@ -31,11 +32,31 @@ struct ReceiverRow {
|
||||
std::uint16_t signaling_port = 0;
|
||||
};
|
||||
|
||||
// Quality presets: a CRF (visual quality target) paired with a VBV max
|
||||
// bitrate that bounds network bursts. The presets map directly to common
|
||||
// CLI invocations.
|
||||
struct QualityPreset {
|
||||
std::string label;
|
||||
int crf;
|
||||
int bitrate_kbps;
|
||||
};
|
||||
|
||||
const std::vector<QualityPreset>& quality_presets() {
|
||||
static const std::vector<QualityPreset> presets = {
|
||||
{"Low bandwidth", 28, 2000},
|
||||
{"Standard", 22, 4000},
|
||||
{"Sharp", 18, 8000},
|
||||
{"Very sharp", 16, 12000},
|
||||
{"Maximum", 14, 20000},
|
||||
};
|
||||
return presets;
|
||||
}
|
||||
|
||||
class SenderWindow : public Gtk::ApplicationWindow {
|
||||
public:
|
||||
SenderWindow() {
|
||||
set_title("screencast");
|
||||
set_default_size(440, 400);
|
||||
set_default_size(440, 440);
|
||||
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 8);
|
||||
set_child(*box);
|
||||
@@ -64,22 +85,40 @@ class SenderWindow : public Gtk::ApplicationWindow {
|
||||
[this](Gtk::ListBoxRow*) { Glib::signal_idle().connect_once([this] { update_sensitivity(); }); });
|
||||
scrolled_->set_child(*receiver_list_);
|
||||
|
||||
// Bitrate.
|
||||
auto* bitrate_box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
box->append(*bitrate_box);
|
||||
// Quality preset dropdown.
|
||||
auto* quality_box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
box->append(*quality_box);
|
||||
auto* quality_label = Gtk::make_managed<Gtk::Label>();
|
||||
quality_label->set_text("Quality:");
|
||||
quality_label->set_halign(Gtk::Align::START);
|
||||
quality_box->append(*quality_label);
|
||||
quality_combo_ = Gtk::make_managed<Gtk::DropDown>();
|
||||
auto preset_list = Gtk::StringList::create({"placeholder"});
|
||||
preset_list->remove(0);
|
||||
for (const QualityPreset& preset : quality_presets()) {
|
||||
preset_list->append(preset.label);
|
||||
}
|
||||
quality_combo_->set_model(preset_list);
|
||||
quality_combo_->set_selected(1); // Standard
|
||||
quality_combo_->property_selected().signal_changed().connect([this] { on_preset_changed(); });
|
||||
quality_combo_->set_hexpand(true);
|
||||
quality_box->append(*quality_combo_);
|
||||
|
||||
// Fine-tuning: bitrate cap and CRF, updated by the preset.
|
||||
auto* detail_box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
box->append(*detail_box);
|
||||
bitrate_label_ = Gtk::make_managed<Gtk::Label>();
|
||||
bitrate_label_->set_hexpand(true);
|
||||
bitrate_label_->set_halign(Gtk::Align::START);
|
||||
bitrate_box->append(*bitrate_label_);
|
||||
detail_box->append(*bitrate_label_);
|
||||
bitrate_scale_ = Gtk::make_managed<Gtk::Scale>(Gtk::Orientation::HORIZONTAL);
|
||||
bitrate_scale_->set_range(500.0, 20000.0);
|
||||
bitrate_scale_->set_value(4000.0);
|
||||
bitrate_scale_->set_increments(500.0, 1000.0);
|
||||
bitrate_scale_->set_draw_value(false);
|
||||
bitrate_scale_->set_hexpand(true);
|
||||
bitrate_scale_->signal_value_changed().connect([this] { update_bitrate_label(); });
|
||||
bitrate_box->append(*bitrate_scale_);
|
||||
update_bitrate_label();
|
||||
bitrate_scale_->signal_value_changed().connect([this] { update_quality_labels(); });
|
||||
detail_box->append(*bitrate_scale_);
|
||||
|
||||
start_button_ = Gtk::make_managed<Gtk::Button>();
|
||||
start_button_->set_label("Start");
|
||||
@@ -101,6 +140,7 @@ class SenderWindow : public Gtk::ApplicationWindow {
|
||||
},
|
||||
1);
|
||||
|
||||
update_quality_labels();
|
||||
update_sensitivity();
|
||||
on_refresh();
|
||||
}
|
||||
@@ -112,14 +152,36 @@ class SenderWindow : public Gtk::ApplicationWindow {
|
||||
}
|
||||
|
||||
private:
|
||||
void update_bitrate_label() {
|
||||
bitrate_label_->set_text(std::format("Bitrate: {} kbps", current_bitrate()));
|
||||
}
|
||||
|
||||
int current_bitrate() const {
|
||||
return static_cast<int>(bitrate_scale_->get_value());
|
||||
}
|
||||
|
||||
int current_crf() const {
|
||||
// CRF from the preset, adjusted by the bitrate slider's distance
|
||||
// from the preset's default: moving the slider up from the preset
|
||||
// means the user wants more headroom, so we keep the preset's CRF
|
||||
// (the slider fine-tunes the cap, not the quality target).
|
||||
const auto index = quality_combo_->get_selected();
|
||||
if (index >= quality_presets().size()) {
|
||||
return 22;
|
||||
}
|
||||
return quality_presets()[index].crf;
|
||||
}
|
||||
|
||||
void on_preset_changed() {
|
||||
const auto index = quality_combo_->get_selected();
|
||||
if (index >= quality_presets().size()) {
|
||||
return;
|
||||
}
|
||||
const QualityPreset& preset = quality_presets()[index];
|
||||
bitrate_scale_->set_value(static_cast<double>(preset.bitrate_kbps));
|
||||
update_quality_labels();
|
||||
}
|
||||
|
||||
void update_quality_labels() {
|
||||
bitrate_label_->set_text(std::format("Max bitrate: {} kbps · CRF {}", current_bitrate(), current_crf()));
|
||||
}
|
||||
|
||||
void clear_receiver_rows() {
|
||||
for (Gtk::Widget* row : receiver_rows_) {
|
||||
receiver_list_->remove(*row);
|
||||
@@ -223,12 +285,13 @@ class SenderWindow : public Gtk::ApplicationWindow {
|
||||
const ReceiverRow& receiver = receivers_[static_cast<std::size_t>(index)];
|
||||
const Endpoint signaling{receiver.host, receiver.signaling_port};
|
||||
const int bitrate = current_bitrate();
|
||||
const int crf = current_crf();
|
||||
|
||||
start_button_->set_sensitive(false);
|
||||
status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name));
|
||||
|
||||
worker_ = std::jthread([this, signaling, bitrate](std::stop_token) {
|
||||
auto result = sc::SenderSession::start(signaling, bitrate, 22, sc::CaptureTargetWholeScreen{});
|
||||
worker_ = std::jthread([this, signaling, bitrate, crf](std::stop_token) {
|
||||
auto result = sc::SenderSession::start(signaling, bitrate, crf, 0, sc::CaptureTargetWholeScreen{});
|
||||
if (auto* error = std::get_if<std::string>(&result)) {
|
||||
Glib::signal_idle().connect_once([this, message = *error] {
|
||||
start_button_->set_label("Start");
|
||||
@@ -295,6 +358,7 @@ class SenderWindow : public Gtk::ApplicationWindow {
|
||||
Gtk::Button* refresh_button_ = nullptr;
|
||||
Gtk::ScrolledWindow* scrolled_ = nullptr;
|
||||
Gtk::ListBox* receiver_list_ = nullptr;
|
||||
Gtk::DropDown* quality_combo_ = nullptr;
|
||||
Gtk::Scale* bitrate_scale_ = nullptr;
|
||||
Gtk::Label* bitrate_label_ = nullptr;
|
||||
Gtk::Button* start_button_ = nullptr;
|
||||
|
||||
Reference in New Issue
Block a user