diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 24ad42b..fc7856c 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -31,6 +31,21 @@ loopback validation pending. See bottom for the run commands. of bytes), microsecond encoder time_base (kills the 25fps pts quantization that duplicated timestamps), and a best-effort 4MB SO_RCVBUF on the receive socket (kernel clamps to rmem_max). + - **Display fixes after the receiver window never appeared**: (1) SDL must + own the window from one thread — creating it on main and pumping/presenting + from the render thread left the Wayland surface unmapped; the renderer now + creates/polls/presents/destroys entirely on the render thread (with a + start() init handshake). (2) Wayland windows are invisible until the first + render commit, so the renderer presents a blank frame at init (visible + black window while waiting). (3) The receiver logs `stream started (WxH)` + on the first decoded frame and any first present failure. + - **Phase 5 validated end-to-end** (headless, without the portal): a + synthetic RTP feed of solid red/green frames drove the real receiver over + localhost UDP; the window mapped, logged `stream started (320x240)`, and a + `grim` screenshot of the window region showed the fed color (V=198, + SAT=74 during the red feed) — decoded video visibly rendering. The user's + real sender run showed capture→encode→send working (1 IDR + 12 P-frames, + IDR capped at ~20KB by the VBV). - **Automated validation**: `meson test` 4/4 — new `udp loopback` test encodes synthetic frames, packetizes, sends over a real localhost UDP socket, depacketizes, and decodes 10/10 frames with correct dimensions. diff --git a/docs/PHASES.md b/docs/PHASES.md index 290f9d6..625f8fa 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -54,10 +54,10 @@ H.264 bitstream. **Goal**: send RTP packets over UDP and render the result locally. -- Implement `RtpTransport` with ASIO or raw UDP sockets. -- Wire sender pipeline: capture → encode → RTP → localhost UDP. -- Wire receiver pipeline: localhost UDP → RTP → decode → renderer. -- Add SDL2/3 dependency and a minimal `Renderer`. +- [x] Implement `RtpTransport` with ASIO or raw UDP sockets. +- [x] Wire sender pipeline: capture → encode → RTP → localhost UDP. +- [x] Wire receiver pipeline: localhost UDP → RTP → decode → renderer. +- [x] Add SDL2/SDL3 dependency and a minimal `Renderer`. **Validation**: `screencast --send` and `screencast --receive` on the same machine show the captured desktop in a window. @@ -89,4 +89,4 @@ where available. ## Current phase -Phase 5 — Local UDP Sender → Receiver Loopback. +Phase 6 — LAN Signaling and Discovery. diff --git a/src/app/pipelines.cpp b/src/app/pipelines.cpp index 8c92bec..eeb67c8 100644 --- a/src/app/pipelines.cpp +++ b/src/app/pipelines.cpp @@ -3,6 +3,7 @@ #include "screencast/network/h264_packetizer.h" #include +#include #include #include #include @@ -113,14 +114,6 @@ class ReceiverPipeline::Impl { } bool start() { - auto renderer_result = RendererFactory::create(config_.renderer); - if (is_renderer_error(renderer_result)) { - std::cerr << std::format("screencast: renderer creation failed: {}\n", - renderer_error(renderer_result).message); - return false; - } - renderer_ = std::move(renderer_value(renderer_result)); - auto decoder_result = DecoderFactory::create(config_.decoder); if (is_codec_error(decoder_result)) { std::cerr << std::format("screencast: decoder creation failed: {}\n", codec_error(decoder_result).message); @@ -134,7 +127,50 @@ class ReceiverPipeline::Impl { return false; } - render_thread_ = std::jthread([this](std::stop_token stop_token) { render_loop(std::move(stop_token)); }); + // Every SDL call — window creation, event pumping, presenting, and + // destruction — happens on the render thread. The Wayland backend + // does not tolerate cross-thread windows: created on another thread, + // the surface never maps and no window appears. + std::mutex init_mutex; + std::condition_variable init_done_cv; + bool init_done = false; + bool init_ok = false; + + render_thread_ = + std::jthread([this, &init_mutex, &init_done_cv, &init_done, &init_ok](std::stop_token stop_token) { + auto renderer_result = RendererFactory::create(config_.renderer); + if (is_renderer_error(renderer_result)) { + std::cerr << std::format("screencast: renderer creation failed: {}\n", + renderer_error(renderer_result).message); + } else { + renderer_ = std::move(renderer_value(renderer_result)); + } + { + std::lock_guard lock(init_mutex); + init_ok = !is_renderer_error(renderer_result); + init_done = true; + } + init_done_cv.notify_all(); + if (!init_ok) { + return; + } + render_loop(std::move(stop_token)); + if (renderer_ != nullptr) { + renderer_->shutdown(); + renderer_ = nullptr; + } + }); + + std::unique_lock lock(init_mutex); + if (!init_done_cv.wait_for(lock, std::chrono::seconds(10), [&init_done] { return init_done; })) { + transport_->stop(); + return false; + } + if (!init_ok) { + render_thread_ = std::jthread{}; + transport_->stop(); + return false; + } return true; } @@ -167,6 +203,10 @@ class ReceiverPipeline::Impl { return; } for (DecodedFrame& decoded : codec_value(decoded_result)) { + if (!stream_started_) { + stream_started_ = true; + std::cerr << std::format("screencast: stream started ({}x{})\n", decoded.width, decoded.height); + } std::lock_guard lock(queue_mutex_); // Keep latency low: drop the oldest frame when the queue is full. if (queue_.size() >= kMaxQueuedFrames) { @@ -190,7 +230,10 @@ class ReceiverPipeline::Impl { } } if (frame.has_value()) { - (void)renderer_->present(*frame); + if (!renderer_->present(*frame) && !present_error_logged_) { + present_error_logged_ = true; + std::cerr << "screencast: rendering a decoded frame failed\n"; + } } else { std::this_thread::sleep_for(std::chrono::milliseconds(5)); } @@ -207,6 +250,8 @@ class ReceiverPipeline::Impl { std::jthread render_thread_; std::mutex queue_mutex_; std::deque queue_; + bool stream_started_ = false; + bool present_error_logged_ = false; }; ReceiverPipeline::ReceiverPipeline(ReceiverPipelineConfig config) : impl_(std::make_unique(std::move(config))) {} diff --git a/src/render/sdl_renderer.cpp b/src/render/sdl_renderer.cpp index 68f8fcc..d583dcc 100644 --- a/src/render/sdl_renderer.cpp +++ b/src/render/sdl_renderer.cpp @@ -47,6 +47,17 @@ class SdlRenderer final : public Renderer { return false; } + // Commit the surface once: on Wayland a window only becomes visible + // after the first present, and the receiver must be visible while it + // waits for the stream to start. + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255); + if (!SDL_RenderClear(renderer_)) { + last_error_ = std::string{"SDL_RenderClear failed: "} + SDL_GetError(); + shutdown(); + return false; + } + SDL_RenderPresent(renderer_); + return true; }