feat(app): add PLI feedback, jitter reordering, and hardware decode

Loss recovery for the streaming path:

- PLI over signaling: the depacketizer now reports damaged frames
  (DepacketizeResult) and the receiver asks the sender for a keyframe
  (SessionPli, rate-limited to one per 500 ms). The sender keeps the
  signaling channel open during the session and honors PLIs through
  the new thread-safe SenderPipeline::request_keyframe(). Recovery
  takes one frame time instead of waiting out the GOP.
- RtpJitterBuffer: reorders RTP packets by sequence number (16 packets
  / 60 ms) before the in-order depacketizer, so Wi-Fi reordering is
  not misread as loss; in-order streams release immediately, and a
  straggler older than the delivered sequence is discarded.
- Hardware H.264 decode probe: DecoderFactory tries h264_v4l2m2m (the
  VideoCore path on the Pi) with an automatic software fallback and a
  clear journal line for the chosen path; --swdecode opts out.

Validated: PLI end-to-end with a probe that drops a mid-keyframe
packet over real UDP (receiver logged the damaged frame and the PLI
arrived with the session id); hardware probe fails cleanly and falls
back on this desktop; jitter reordering covered by unit tests.
meson test 5/5 in both build configurations, valgrind clean.
This commit is contained in:
2026-09-08 16:54:09 +02:00
parent 41f71fd217
commit 943596da6d
16 changed files with 355 additions and 42 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ namespace {
void print_usage() {
std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n"
" screencast --discover [--timeout SECONDS]\n"
"\n"
"--send without --peer discovers a receiver on the LAN and requires\n"
@@ -112,6 +112,8 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
receive.signaling_port = port;
} else if (argument == "--fullscreen") {
receive.fullscreen = true;
} else if (argument == "--swdecode") {
receive.software_decode = true;
} else if (argument == "--timeout") {
std::string_view value;
if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) ||
+12 -1
View File
@@ -167,7 +167,8 @@ int negotiate_and_stream(sc::SignalingChannel& channel, const sc::Endpoint& sign
return 1;
}
const sc::SessionAnswer answer = answer_future.get();
channel.disconnect();
// The channel stays open: the receiver sends PLI keyframe requests over
// it during the session.
if (answer.session_id != offer.session_id) {
std::cerr << "screencast: session mismatch in the receiver's answer\n";
@@ -196,12 +197,21 @@ int negotiate_and_stream(sc::SignalingChannel& channel, const sc::Endpoint& sign
sc::SenderPipeline pipeline{std::move(config)};
if (!pipeline.start()) {
channel.disconnect();
return 1;
}
channel.on_message([&](const sc::SignalingMessage& message) {
const sc::SessionPli* pli = std::get_if<sc::SessionPli>(&message);
if (pli != nullptr && pli->session_id == offer.session_id) {
pipeline.request_keyframe();
std::cerr << "screencast: receiver requested a keyframe\n";
}
});
while (!g_interrupted.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
pipeline.stop();
channel.disconnect();
return 0;
}
@@ -289,6 +299,7 @@ int run_receiver(const sc::ReceiveCommand& command) {
sc::ReceiverPipelineConfig config;
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<std::uint16_t>(command.local_rtp_port)};
config.signaling_port = static_cast<std::uint16_t>(command.signaling_port);
config.decoder.hardware_accel = !command.software_decode;
// Fullscreen is automatic under KMSDRM (headless); the flag forces it
// on desktop sessions.
config.renderer.fullscreen = command.fullscreen;
+50 -3
View File
@@ -64,9 +64,17 @@ class SenderPipeline::Impl {
transport_->stop();
}
void request_keyframe() {
keyframe_requested_.store(true);
}
private:
void run(std::stop_token stop_token) {
while (!stop_token.stop_requested()) {
if (keyframe_requested_.exchange(false) && encoder_ != nullptr) {
encoder_->request_keyframe();
}
const std::optional<CapturedFrame> frame = capture_->next_frame();
if (!frame.has_value()) {
break;
@@ -109,8 +117,13 @@ class SenderPipeline::Impl {
H264Packetizer packetizer_;
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
std::jthread run_thread_;
std::atomic<bool> keyframe_requested_{false};
};
void SenderPipeline::request_keyframe() {
impl_->request_keyframe();
}
SenderPipeline::SenderPipeline(SenderPipelineConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
SenderPipeline::~SenderPipeline() = default;
@@ -238,6 +251,7 @@ class ReceiverPipeline::Impl {
signaling_ = nullptr;
discovery_ = nullptr;
transport_->stop();
jitter_.clear();
renderer_ = nullptr;
decoder_ = nullptr;
{
@@ -248,13 +262,24 @@ class ReceiverPipeline::Impl {
private:
void on_packet(RtpPacket packet) {
std::optional<std::vector<std::byte>> access_unit = depacketizer_.depacketize(packet);
if (!access_unit.has_value()) {
// Absorb reordering (Wi-Fi) before the in-order depacketizer, so a
// late packet is not misread as loss.
for (RtpPacket& ordered : jitter_.push(std::move(packet))) {
deliver_packet(ordered);
}
}
void deliver_packet(const RtpPacket& packet) {
const DepacketizeResult result = depacketizer_.depacketize(packet);
if (result.frame_dropped) {
maybe_send_pli();
}
if (!result.access_unit.has_value()) {
return;
}
EncodedFrame encoded;
encoded.data = std::move(*access_unit);
encoded.data = std::move(*result.access_unit);
encoded.rtp_timestamp = packet.header.timestamp;
encoded.is_keyframe = false;
@@ -282,6 +307,7 @@ class ReceiverPipeline::Impl {
if (offer == nullptr) {
return;
}
session_id_ = offer->session_id;
SessionAnswer answer;
answer.session_id = offer->session_id;
// Empty address: the sender targets the address of its signaling
@@ -290,6 +316,23 @@ class ReceiverPipeline::Impl {
signaling_->send(answer);
}
// Ask the sender for a keyframe after a damaged frame, rate-limited so
// sustained loss cannot flood the signaling channel.
void maybe_send_pli() {
if (signaling_ == nullptr || session_id_.empty()) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (now - last_pli_time_ < kPliMinInterval) {
return;
}
last_pli_time_ = now;
SessionPli pli;
pli.session_id = session_id_;
signaling_->send(pli);
std::cerr << std::format("screencast: frame damaged; requesting a keyframe\n");
}
void render_loop(std::stop_token stop_token) {
while (!stop_token.stop_requested()) {
if (!renderer_->poll_events()) {
@@ -315,17 +358,21 @@ class ReceiverPipeline::Impl {
}
static constexpr std::size_t kMaxQueuedFrames = 3;
static constexpr std::chrono::milliseconds kPliMinInterval{500};
ReceiverPipelineConfig config_;
std::unique_ptr<Renderer> renderer_;
std::unique_ptr<Decoder> decoder_;
H264Depacketizer depacketizer_;
RtpJitterBuffer jitter_;
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
std::unique_ptr<SignalingChannel> signaling_;
std::unique_ptr<DiscoveryService> discovery_;
std::jthread render_thread_;
std::mutex queue_mutex_;
std::deque<DecodedFrame> queue_;
std::string session_id_;
std::chrono::steady_clock::time_point last_pli_time_{};
bool stream_started_ = false;
bool present_error_logged_ = false;
};