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:
+11
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) ||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user