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
template (`__SC_RECEIVER_BIN__`/`__SC_RECEIVER_USER__`); the install
script substitutes and enables it (opt out: `SC_RECEIVER_SERVICE=0`),
adding the run user to video/render/input for headless KMSDRM.
Lifecycle-validated with a user unit on this machine: SIGTERM stop →
clean exit (success), mDNS withdrawn, no restart.
adding the run user to video/render/input for headless KMSDRM. The unit
gets a controlling tty (StandardInput=tty, tty1) because SDL's KMSDRM
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
process holding an avahi registration leaves stale daemon records; the
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
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
+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
`/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
the receiver runs inside it (uncomment the `Environment=` lines in the
service file as described above).
+1
View File
@@ -17,6 +17,7 @@ struct ReceiveCommand {
std::string_view peer_address; // optional, informational
int local_rtp_port = 5004;
int signaling_port = 5005;
bool fullscreen = false;
};
struct DiscoverCommand {
+3
View File
@@ -12,6 +12,9 @@ struct RendererConfig {
std::string window_title = "screencast receiver";
int initial_width = 1280;
int initial_height = 720;
// Borderless fullscreen. On the headless KMSDRM backend (no window
// manager) fullscreen is enabled automatically.
bool fullscreen = false;
};
class Renderer {
+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]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen]\n"
" screencast --discover [--timeout SECONDS]\n"
"\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;
}
receive.signaling_port = port;
} else if (argument == "--fullscreen") {
receive.fullscreen = true;
} else if (argument == "--timeout") {
std::string_view value;
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;
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);
// Fullscreen is automatic under KMSDRM (headless); the flag forces it
// on desktop sessions.
config.renderer.fullscreen = command.fullscreen;
sc::ReceiverPipeline pipeline{std::move(config)};
if (!pipeline.start()) {
+21 -1
View File
@@ -34,12 +34,23 @@ class SdlRenderer final : public Renderer {
}
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) {
last_error_ = std::string{"SDL_CreateWindow failed: "} + SDL_GetError();
shutdown();
return false;
}
if (fullscreen_) {
(void)SDL_HideCursor();
}
renderer_ = SDL_CreateRenderer(window_, nullptr);
if (renderer_ == nullptr) {
@@ -76,6 +87,10 @@ class SdlRenderer final : public Renderer {
if (!SDL_UpdateTexture(texture_, nullptr, frame.rgba_pixels.data(), pitch)) {
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)) {
return false;
}
@@ -132,6 +147,10 @@ class SdlRenderer final : public Renderer {
}
texture_width_ = width;
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;
}
@@ -139,6 +158,7 @@ class SdlRenderer final : public Renderer {
std::string last_error_;
bool sdl_inited_ = false;
bool closed_ = false;
bool fullscreen_ = false;
SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
SDL_Texture* texture_ = nullptr;