feat(render): fullscreen headless receiver with letterboxing

A headless receiver has no window manager, so a windowed window is
meaningless there — the screencast should simply fill the screen. The
renderer now goes borderless fullscreen automatically when SDL runs
the KMSDRM backend, and --fullscreen forces the same behavior in
desktop sessions. The video keeps its aspect ratio via
SDL_SetRenderLogicalPresentation(LETTERBOX): a 4:3 desktop on a 16:9
TV renders with black bars instead of a stretched picture, the cursor
is hidden, and the clear-before-draw keeps the bars black.

Verified live on a 3440x1440 monitor: the --fullscreen receiver window
covered the entire display while a 4:3 synthetic feed rendered with
pure-black pillarbox bars (Y=0/SAT=0) and a saturated-red center
(V=254). meson test 5/5 in both build configurations.
This commit is contained in:
2026-09-07 13:18:08 +02:00
parent 47905004eb
commit 7c04506dde
8 changed files with 50 additions and 6 deletions
+11 -3
View File
@@ -34,9 +34,17 @@ loopback; current phase is Phase 7.
- **systemd autostart**: `systemd/screencast-receiver.service` is a - **systemd autostart**: `systemd/screencast-receiver.service` is a
template (`__SC_RECEIVER_BIN__`/`__SC_RECEIVER_USER__`); the install template (`__SC_RECEIVER_BIN__`/`__SC_RECEIVER_USER__`); the install
script substitutes and enables it (opt out: `SC_RECEIVER_SERVICE=0`), script substitutes and enables it (opt out: `SC_RECEIVER_SERVICE=0`),
adding the run user to video/render/input for headless KMSDRM. adding the run user to video/render/input for headless KMSDRM. The unit
Lifecycle-validated with a user unit on this machine: SIGTERM stop → gets a controlling tty (StandardInput=tty, tty1) because SDL's KMSDRM
clean exit (success), mDNS withdrawn, no restart. backend needs one for VT handling.
- **Fullscreen headless rendering**: under the KMSDRM video driver (no
window manager) the renderer goes fullscreen automatically; --fullscreen
forces it on desktops. Aspect is preserved via
SDL_SetRenderLogicalPresentation(LETTERBOX) (verified: 4:3 feed on a
3440x1440 monitor rendered with black pillarbox bars; --fullscreen
window covered the full monitor). The service lifecycle was validated
with a user unit: SIGTERM stop → clean exit (success), mDNS withdrawn,
no restart.
- **mDNS operational lesson (hit during validation)**: `kill -9` on a - **mDNS operational lesson (hit during validation)**: `kill -9` on a
process holding an avahi registration leaves stale daemon records; the process holding an avahi registration leaves stale daemon records; the
service then browses but never resolves (timeout for every peer) until service then browses but never resolves (timeout for every peer) until
+3 -1
View File
@@ -57,7 +57,9 @@ installs and enables a systemd service so the receiver starts at boot —
Performance note: decoding is software H.264; on very small boards expect Performance note: decoding is software H.264; on very small boards expect
smooth playback for modest resolutions and reduced frame rates at high smooth playback for modest resolutions and reduced frame rates at high
resolutions. Hardware decode is planned for Phase 7. resolutions. Hardware decode is planned for Phase 7. On a headless console
the receiver runs fullscreen automatically with aspect-preserving
letterboxing.
## Architecture ## Architecture
+5
View File
@@ -102,6 +102,11 @@ the recommended setup for small boards (Raspberry Pi OS **Lite**):
3. Disable console blanking: append `consoleblank=0` to 3. Disable console blanking: append `consoleblank=0` to
`/boot/firmware/cmdline.txt` and reboot. `/boot/firmware/cmdline.txt` and reboot.
Under KMSDRM the receiver goes **fullscreen automatically**, with
aspect-preserving letterboxing (a 4:3 desktop on a 16:9 TV shows black
bars, not a stretched picture) and no mouse cursor. On desktop sessions
`--fullscreen` forces the same behavior.
A window manager only comes with the desktop-session alternative, where A window manager only comes with the desktop-session alternative, where
the receiver runs inside it (uncomment the `Environment=` lines in the the receiver runs inside it (uncomment the `Environment=` lines in the
service file as described above). service file as described above).
+1
View File
@@ -17,6 +17,7 @@ struct ReceiveCommand {
std::string_view peer_address; // optional, informational std::string_view peer_address; // optional, informational
int local_rtp_port = 5004; int local_rtp_port = 5004;
int signaling_port = 5005; int signaling_port = 5005;
bool fullscreen = false;
}; };
struct DiscoverCommand { struct DiscoverCommand {
+3
View File
@@ -12,6 +12,9 @@ struct RendererConfig {
std::string window_title = "screencast receiver"; std::string window_title = "screencast receiver";
int initial_width = 1280; int initial_width = 1280;
int initial_height = 720; int initial_height = 720;
// Borderless fullscreen. On the headless KMSDRM backend (no window
// manager) fullscreen is enabled automatically.
bool fullscreen = false;
}; };
class Renderer { class Renderer {
+3 -1
View File
@@ -9,7 +9,7 @@ namespace {
void print_usage() { void print_usage() {
std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n" std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n"
" screencast --receive [--port PORT] [--signaling-port PORT]\n" " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen]\n"
" screencast --discover [--timeout SECONDS]\n" " screencast --discover [--timeout SECONDS]\n"
"\n" "\n"
"--send without --peer discovers a receiver on the LAN and requires\n" "--send without --peer discovers a receiver on the LAN and requires\n"
@@ -110,6 +110,8 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
return std::nullopt; return std::nullopt;
} }
receive.signaling_port = port; receive.signaling_port = port;
} else if (argument == "--fullscreen") {
receive.fullscreen = true;
} else if (argument == "--timeout") { } else if (argument == "--timeout") {
std::string_view value; std::string_view value;
if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) || if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) ||
+3
View File
@@ -214,6 +214,9 @@ int run_receiver(const sc::ReceiveCommand& command) {
sc::ReceiverPipelineConfig config; sc::ReceiverPipelineConfig config;
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<std::uint16_t>(command.local_rtp_port)}; 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.signaling_port = static_cast<std::uint16_t>(command.signaling_port);
// Fullscreen is automatic under KMSDRM (headless); the flag forces it
// on desktop sessions.
config.renderer.fullscreen = command.fullscreen;
sc::ReceiverPipeline pipeline{std::move(config)}; sc::ReceiverPipeline pipeline{std::move(config)};
if (!pipeline.start()) { if (!pipeline.start()) {
+21 -1
View File
@@ -34,12 +34,23 @@ class SdlRenderer final : public Renderer {
} }
sdl_inited_ = true; sdl_inited_ = true;
window_ = SDL_CreateWindow(config_.window_title.c_str(), config_.initial_width, config_.initial_height, 0); // Without a window manager (SDL KMSDRM backend) a windowed window is
// meaningless: go fullscreen automatically. --fullscreen forces it
// on desktop sessions too.
const char* video_driver = SDL_GetCurrentVideoDriver();
fullscreen_ = config_.fullscreen || (video_driver != nullptr && std::string_view{video_driver} == "kmsdrm");
const SDL_WindowFlags window_flags = fullscreen_ ? SDL_WINDOW_FULLSCREEN : 0;
window_ =
SDL_CreateWindow(config_.window_title.c_str(), config_.initial_width, config_.initial_height, window_flags);
if (window_ == nullptr) { if (window_ == nullptr) {
last_error_ = std::string{"SDL_CreateWindow failed: "} + SDL_GetError(); last_error_ = std::string{"SDL_CreateWindow failed: "} + SDL_GetError();
shutdown(); shutdown();
return false; return false;
} }
if (fullscreen_) {
(void)SDL_HideCursor();
}
renderer_ = SDL_CreateRenderer(window_, nullptr); renderer_ = SDL_CreateRenderer(window_, nullptr);
if (renderer_ == nullptr) { if (renderer_ == nullptr) {
@@ -76,6 +87,10 @@ class SdlRenderer final : public Renderer {
if (!SDL_UpdateTexture(texture_, nullptr, frame.rgba_pixels.data(), pitch)) { if (!SDL_UpdateTexture(texture_, nullptr, frame.rgba_pixels.data(), pitch)) {
return false; return false;
} }
// Clear first so the letterbox bars stay black between frames.
if (!SDL_RenderClear(renderer_)) {
return false;
}
if (!SDL_RenderTexture(renderer_, texture_, nullptr, nullptr)) { if (!SDL_RenderTexture(renderer_, texture_, nullptr, nullptr)) {
return false; return false;
} }
@@ -132,6 +147,10 @@ class SdlRenderer final : public Renderer {
} }
texture_width_ = width; texture_width_ = width;
texture_height_ = height; texture_height_ = height;
// Present the video at its own aspect ratio: letterbox anything
// else (e.g. a 3:2 desktop on a 16:9 TV) instead of stretching.
(void)SDL_SetRenderLogicalPresentation(renderer_, width, height, SDL_LOGICAL_PRESENTATION_LETTERBOX);
return true; return true;
} }
@@ -139,6 +158,7 @@ class SdlRenderer final : public Renderer {
std::string last_error_; std::string last_error_;
bool sdl_inited_ = false; bool sdl_inited_ = false;
bool closed_ = false; bool closed_ = false;
bool fullscreen_ = false;
SDL_Window* window_ = nullptr; SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr; SDL_Renderer* renderer_ = nullptr;
SDL_Texture* texture_ = nullptr; SDL_Texture* texture_ = nullptr;