Display Output
A display output plays a flow’s video to a locally-attached HDMI or DisplayPort connector and (optionally) routes its audio to an ALSA device. It’s the broadcast equivalent of a confidence monitor — the operator standing next to the edge box can see and hear what’s leaving the gateway, without having to spin up a software decoder somewhere else.
The output is Linux-only and gated on the display Cargo feature, which is on by default in every release tarball.
When to use it
Section titled “When to use it”- Confidence monitor at site — the on-site engineer sees PGM on the HDMI out next to the edge box.
- OB / live-event control room — drive a wall-mounted screen direct from the edge, no PC running ffplay or VLC.
- Studio rack — drive a 1U preview monitor without an extra appliance in the chain.
This is not a low-latency director surface — it does not lock to PTP or to the PCR clock. For those workflows, use the Switcher PGM/PVW console with a real video router downstream.
Prerequisites
Section titled “Prerequisites”The runtime apt packages are part of every modern Linux base install:
sudo apt updatesudo apt install libdrm2 libasound2t64 libudev1On Ubuntu 22.04 / Debian 12 use plain libasound2; on Ubuntu 24.04+ it was renamed to libasound2t64 (Ubuntu’s t64 time_t transition). Same libasound.so.2 either way.
These cover KMS (Linux DRM mode-setting) and ALSA. On a strictly headless box they cause no side effects — the edge simply doesn’t advertise the display capability and any flow with a display output stays passive.
Device permissions
Section titled “Device permissions”/dev/dri/* and /dev/snd/* are group-gated (video / render / audio) on every distribution. The Ubuntu service install handles this automatically — the unit ships SupplementaryGroups=video render audio and the installer adds the bilbycast user to all three groups. If you run the edge under your own user or a custom unit, grant the same membership:
sudo usermod -aG video,render,audio <user> # takes effect on next loginMissing audio membership fails deceptively: video keeps rendering while every ALSA open fails with the misleading ALSA lib confmisc.c: (snd_config_get_card) Cannot get card index for N — that’s a permission error on /dev/snd/controlCN, not a missing sound card. Desktop sessions often mask the problem because systemd-logind grants a temporary ACL on /dev/snd to the active local seat, which disappears on reboot or when only SSH sessions exist. The edge raises Critical display_audio_open_failed events, and after 10 consecutive failures latches display_audio_disabled_persistent_failure (video continues; restart the flow after fixing the permission).
If you build the edge from source, the matching dev packages are libdrm-dev libasound2-dev libudev-dev and the feature is enabled by default:
cargo build --release # display feature is on by defaultcargo build --release --no-default-features --features tls,webrtc # explicit opt-outAdding a display output via the manager UI
Section titled “Adding a display output via the manager UI”- Open Admin → Nodes, click the edge, Configure, then the Outputs tab.
- + Add Output, pick Display (HDMI / DisplayPort) as the type.
- Pick a connector from the Device dropdown — the manager populates it from the
HealthPayload.display_deviceslist the edge advertises on every health tick. - Pick an Audio device (an ALSA id like
hw:0,3,plughw:0,3, ordefault). Leave blank for video-only. - Optional: set Program (for MPTS sources), Audio track, Audio channel pair, and Scaling mode. (The older Resolution / Refresh Hz fields are deprecated and ignored at runtime — see the note under Config fields.)
- Save, then attach the output to a flow on the Flows tab.
If the dropdown is empty, the host has no display advertised — either the edge was built without the display feature, or it’s running on a headless box with no connectors plugged in.
A background poller re-enumerates the host’s KMS connectors every 10 s and republishes the list when it changes, so a monitor plugged in after boot reaches the dropdown (and re-arms the display capability) without an edge restart. It is a poll, not a udev event, so allow up to ~10 s plus one ~15 s health tick before the new connector appears.
Config fields
Section titled “Config fields”Outputs are JSON top-level entities in config.json. The minimum:
{ "id": "out-confidence", "name": "Green-room HDMI", "type": "display", "device": "HDMI-A-1"}| Field | Type | Default | Notes |
|---|---|---|---|
id |
string | — | Unique within the config. |
name |
string | — | Free-form label. |
type |
string | — | Always "display". |
device |
string | — | KMS connector name from the edge’s display enumeration: "HDMI-A-1", "DP-2", "DVI-D-1", …. Validated against ^[A-Z][A-Z0-9-]{0,63}$. |
audio_device |
string | null |
ALSA device id ("hw:0,3", "plughw:0,3", "default", "sysdefault", "pulse"). Omit for video-only. |
program_number |
u16 | null |
MPTS program filter (1-based; 0 is reserved). null selects the lowest program in the active input’s PAT. |
audio_track_index |
u8 | null |
Audio elementary-stream index within the chosen program. null selects the first audio track. Must be < 16. |
audio_channel_pair |
[u8; 2] |
[0, 1] |
Stereo pair to render from decoded multichannel audio. Both indices must be < 8 and not equal. |
scaling_mode |
string | "match_source" |
How the panel’s KMS mode is chosen. match_source (default) re-modesets to the smallest mode that covers the source’s (width, height), on every source-shape change — best A/V sync, no scaling. monitor_native picks the connector’s preferred (panel-native) mode once at task startup and holds it, letting libswscale upscale the source to fill it; pick it for fixed-mode panels (HDCP-locked, signage) and desktop monitors that handle their native mode best. |
sync_mode |
string | "vsync_to_display" |
"vsync_to_display" (default, audio-master) or "genlock" (locks the display’s audio to the flow master clock so the panel stays rate-coherent with the flow’s wire outputs; video still follows the audio playout, so lip-sync is identical either way). |
hw_decode |
string | "auto" |
"auto", "cpu", "nvdec", "qsv", "vaapi", or "rkmpp". Auto resolves to vaapi ≻ nvdec ≻ qsv ≻ rkmpp ≻ cpu against the host’s probed capabilities. "rkmpp" is the Rockchip RK3568 / RK3588 hardware decode path (shipped in the aarch64-linux-rockchip release). See Codec matrix. |
show_audio_bars |
bool | false |
Render translucent audio level bars, plus a single-line stream-info header, as an ARGB8888 overlay plane composed at vblank. Where the host cannot program a second plane the strip is baked into the video frame on the CPU instead, which takes a zero-copy frame off the PRIME path — see Audio level bars overlay. |
present_lead_ms |
u32 | null (decoder-dependent) |
How far ahead of its present instant a frame is queued, 0–1000 ms, so a delivery burst is served from a backlog instead of stalling the present. It costs exactly that much added display latency. Unset is not the same as 0: with the field absent the lead is picked from the decoder that is actually running — the RKMPP zero-copy path gets 200 ms (where its dose-response saturates; the elbow is ~120 ms), every other backend gets 0 and is bit-for-bit unchanged. Set 0 to disable it explicitly where the latency matters more than the stutter. Clamped at run time to what the decode queue can hold (a third of its depth) — a deeper lead spills onto frames_dropped_mpsc_full instead of buffering. Applies only to outputs with no audio_device: an audio-enabled output paces against the measured ALSA playout position and ignores this entirely. |
present_vblank_cadence |
bool | false |
Assign each frame to whole vblanks on the panel’s real raster instead of computing a wall-clock present instant and hoping it lands on the right one. At 25p on a 50 Hz panel it holds every frame for 2 vblanks; at 24p on 60 Hz it generates true 2:3 pulldown (3,2,3,2); crystal drift is absorbed as one longer or shorter hold per beat instead of a continuous sub-frame slide. It engages only where the flip clock has earned trust, the measured rates give a schedulable ratio, and the source rate has stabilised — anything else silently keeps the wall-clock path, and a source faster than the panel (60p on 50 Hz) is declined outright because that needs frame dropping, a different algorithm that is not implemented. Video-only outputs: an output with an audio_device paces against ALSA, so the resolver declines to engage. Off by default pending fleet validation. |
How A/V sync works
Section titled “How A/V sync works”The audio task is the master clock. The display task vsync-paces to the connector and dup/drops video frames to track the audio clock:
- Frames more than one frame-period behind the audio clock are dropped (
frames_dropped_late). - Frames more than one frame-period ahead are held for an extra vsync (
frames_repeated). - An ALSA xrun (
EPIPE) recovers viasnd_pcm_prepare()and is counted inaudio_underruns— the audio clock keeps moving but the renderer doesn’t nudge it, so the dup/drop algorithm naturally re-aligns.
The video-vs-audio offset is published as a signed EMA on the per-output display_stats.av_sync_offset_ms. No event is raised on drift — the manager polls the field continuously. For late frames read frames_dropped_late and the display_frame_loss_sustained event.
Supported codecs
Section titled “Supported codecs”- Video: H.264 + H.265. Decode resolves through the codec matrix —
hw_decode: "auto"(default) picksVAAPI ≻ NVDEC ≻ QSV ≻ RKMPP ≻ CPUagainst the host’s probed capabilities. VAAPI, NVDEC, and RKMPP (Rockchip) support zero-copy scanout (see below); QSV decodes to a GPU surface and downloads to sysmem before scanout. CPU decode via libavcodec is the always-available baseline. - Audio: AAC family via fdk-aac, plus MP2 / AC-3 / E-AC-3 / Opus via libavcodec. No re-encode — every codec decodes to LPCM for ALSA.
Multichannel audio (5.1 / 7.1) is downmixed to the configured stereo pair (audio_channel_pair) — passthrough of compressed audio over HDMI is not supported.
Zero-copy scanout, atomic commit, and HDR
Section titled “Zero-copy scanout, atomic commit, and HDR”The display task drives the connector through Linux DRM/KMS atomic-commit page flips — drmModeAtomicCommit(PAGE_FLIP_EVENT | NONBLOCK). The first commit on a fresh connector carries ALLOW_MODESET plus the CRTC.ACTIVE / CRTC.MODE_ID / CONNECTOR.CRTC_ID writes; subsequent flips skip those and just rotate the framebuffer.
- VAAPI decode → DMA-BUF → KMS scanout is the fastest path. The decoded frame stays in GPU memory; the display task maps it to a DRM PRIME descriptor via
av_hwframe_map, attaches it to a framebuffer withdrmModeAddFB2, and the atomic commit composes it at vblank with no CPU blit. Cross-modifier flips (linear XRGB8888 dumb buffer ↔ tiled NV12 / P010) work without a full-plane reconfigure. - NVDEC decode → CUDA surface → KMS scanout is also zero-copy on hosts with the NVIDIA proprietary driver.
- RKMPP decode → DRM PRIME dma-buf → KMS scanout is the zero-copy path on Rockchip RK3568 / RK3588 (the
aarch64-linux-rockchiprelease,video-decoder-rkmpp+rga-transfer). The RGA 2D accelerator handles the DRM_PRIME → sysmem NV12 transfer in hardware when a copy is unavoidable. - CPU decode allocates an XRGB8888 dumb buffer, blits the decoded frame in, and atomic-commits the dumb buffer — one CPU copy per frame.
Hosts that reject atomic ioctls (EOPNOTSUPP from the kernel, or EINVAL on the first commit) fall back to legacy set_crtc per-frame and emit a single display_atomic_unavailable Warning event so the operator can spot the degradation.
HDR output
Section titled “HDR output”KmsDisplay::open queries the connector’s HDR_OUTPUT_METADATA atomic blob property. When present, panel_hdr_capable() returns true and:
- HDR-on-HDR (PQ / HLG source, HDR-capable panel): the decoded frame stays on the VAAPI zero-copy path. Before each
present_prime, the display task allocates a 30-bytehdr_output_metadatablob with BT.2020 primaries + D65 white point + sensible HDR10 luminance defaults (1000 / 0.005 / 1000 / 400 nits) and folds the connector property write into the same atomic commit as the framebuffer flip. EOTF transitions takeALLOW_MODESETbecause the HDMI / DP sink re-trains. - HDR-on-SDR (HDR source, non-HDR panel): the decode task downloads the VAAPI surface to sysmem via
av_hwframe_transfer_data, applies a CPU LUT tonemap to BT.709, and falls through to the dumb-buffer scanout. One PCIe transfer per HDR frame on Intel iHD; pointer-alias on AMD radeonsi. Adisplay_hdr_tonemap_activeWarning event fires once per flow start so the operator sees the degraded path explicitly.
clear_hdr_output_metadata reverts the connector to SDR signalling on flow stop.
Audio level bars overlay
Section titled “Audio level bars overlay”Setting show_audio_bars: true discovers a second KMS plane (Overlay-type, with Cursor as a fallback) that the CRTC can drive in DRM_FORMAT_ARGB8888. The display task allocates a panel-width × strip-height ARGB8888 dumb buffer and paints translucent-black backgrounds (alpha=0x80) plus opaque bars (alpha=0xFF) into it; the overlay composes at vblank in a single atomic commit alongside the prime framebuffer.
The same strip also carries a left-aligned, single-line stream-info header — codec name, WxH, source fps, HDR-PQ / HDR-HLG, BT.2020, V:0x<pid>, A:0x<pid>, PROG <n> — painted in a warm amber tint so it reads apart from the white per-block audio labels. Fields drop out until the demuxer has locked them rather than showing 0x0 / 0p placeholders, the line is truncated to whatever room is left of the leftmost meter block, and it is skipped entirely below 72 px of space.
The overlay keeps the video on the zero-copy path only where that second plane can actually be programmed. Where it can’t — a host stuck on legacy set_crtc (multi-plane composition needs atomic commit), no Overlay / Cursor plane the CRTC will drive in ARGB8888, or a panel too short for the strip — the edge deliberately downloads each hardware surface (VAAPI, or RKMPP’s DRM_PRIME) to sysmem and CPU-blits it, so the bars stay visible at the cost of one PCIe transfer per frame. That fallback applies at 1920x1080 and below only: above the software-blit ceiling the video stays zero-copy and the bars simply do not render. Read display_stats.bars_overlay_enabled (false = no plane, CPU bake) alongside a rising download_count.
Capacity
Section titled “Capacity”Each running display output consumes resource-budget units on the edge:
- CPU decode → a flat 275 units (libavcodec decode + libswscale convert + the KMS render and audio path).
- Hardware decode (NVDEC / QSV / VAAPI / RKMPP) → a flat 100 units, plus one session booked against that backend’s family limit. The two tiers are alternatives, not additions — an output is charged one or the other, decided by the same decoder resolver
start_output()uses. show_audio_bars→ 15 units on top, for the independent multi-PID audio decoder behind the meters.
The figure is resolution-independent: the cost plan carries no source resolution, so a 4K output is charged exactly what a 1080p one is. Budget the difference yourself when you put 4K on a CPU-decoded output.
The edge does not reject a second display output on the same (device, audio_device) pair. Config validation logs a warning and accepts both, because two redundant flows mapped to one confidence monitor is a supported arrangement. At run time a per-edge claim registry serialises them first-come-first-served: the first output to start holds the connector, the others park and emit Info display_output_waiting (details carry queue_position plus the holder’s holder_flow_id / holder_output_id), then Info display_output_acquired when they are promoted. The manager refuses the duplicate up front instead — see display_device_busy below.
Events and error codes
Section titled “Events and error codes”Most of these are filed under the display event category with details.output_id set. Two sub-families are not: the hardware-decode lifecycle goes under system_resources, and the input-switch pair under flow. Filter the manager’s Events page by error_code rather than by category when chasing a display fault.
| Event | Severity | Trigger |
|---|---|---|
display_started |
Info | Modeset succeeded, ALSA opened (or muted), first frame queued. |
display_stopped |
Info | Cancellation token fired. Includes lifetime frames_displayed, frames_dropped_late, audio_underruns. |
display_mode_set_failed |
Critical | drmModeSetCrtc returned EINVAL / ENOSPC for the chosen resolution / refresh. |
display_audio_open_failed |
Critical | snd_pcm_open returned non-zero, or ALSA writei returned ENODEV mid-stream. |
display_subscriber_lagged |
Warning | broadcast Lagged(n); rate-limited to one event / second. The decoders flush and resync on the next IDR. |
display_hdr_tonemap_active |
Warning | HDR source landed on an SDR panel. The decode falls back to sysmem download + CPU LUT tonemap. Fires once per flow start. |
display_atomic_unavailable |
Warning | Kernel rejected the atomic-commit ioctl. The output falls back to legacy set_crtc per-frame. Fires once per output start. |
display_hw_decode_unavailable_falling_back |
Warning | The backend named in hw_decode is not compiled into this build, or the host cannot provide it. Decided at output start, before a decoder is opened: the output runs on CPU instead. Details carry preference, reason (feature_disabled / driver_missing / probe_unavailable) and fell_back_to: "cpu". |
display_hw_decode_unavailable |
Warning | The requested HW backend could not be opened on this host — four attempts (immediate, then 50 ms, 100 ms, 200 ms) — so the output ran on CPU decode instead, for the rest of the run. decoder_kind then reports "cpu (hw unavailable)". This one is not retried by display_hw_decode_repromote: the backend never opened, so there is no working session to re-arm. |
display_hw_decode_runtime_failed |
Warning | A HW decoder that had opened successfully failed mid-stream, and the output demoted to CPU. |
display_hw_decode_no_frames |
Warning | The HW decoder accepted packets but produced no frame inside the watchdog window, so the output demoted to CPU. |
display_hw_decode_repromote |
Info | A previously demoted output is retrying the hardware decoder after a spell on CPU. |
display_frame_loss_sustained |
Warning | The output showed materially fewer frames than the panel could have presented over the measurement window. Details carry panel_refresh_hz, frames_displayed, frames_presentable, present_shortfall_pct and frames_dropped_mpsc_full — read those alongside upstream_frame_period_us and the cadence_* counters to tell decode-side shedding from present-side stutter. |
display_frame_loss_recovered |
Info | The shortfall cleared. |
display_deinterlace_engaged |
Info | An interlaced source was detected; bob deinterlacing engaged and fields are presented at 2× frame rate. |
display_input_switch_acquiring |
Info | A Take was detected on the flow and the output is waiting for the new source’s first IDR. |
display_input_switch_acquired |
Info | The new source is on the panel; details carry elapsed_ms. |
This table covers the display events you meet in normal operation. The full catalogue — including the claim-registry pair, the HW-decode reset / MPEG-2 pin family, the VAAPI and RKMPP PRIME-export failures and the unsupported-pixel-format cases — is in Edge events and alarms.
Save-time errors that surface as command_ack.error_code on add_output / update_config:
error_code |
Meaning |
|---|---|
display_device_invalid |
device regex failed at config-load OR connector not present in enumerate_displays() at runtime OR build was compiled without the display Cargo feature. |
display_audio_device_invalid |
audio_device regex failed OR ALSA refused to open it. |
display_resolution_unsupported |
The connector advertises no usable mode for the source, or KMS refused the chosen mode. Not driven by the deprecated resolution / refresh_hz fields. |
display_program_not_found |
After 5 s, the demuxer hasn’t seen the configured program_number in the PAT. |
display_audio_track_not_found |
Configured audio_track_index exceeds the PMT’s audio-stream count. |
One code you will meet that is not on that list: display_device_busy. It comes from the manager, not the edge — adding or editing a display output whose (device, audio_device) pair is already used on that node is refused with HTTP 422 and error_code: "display_device_busy" before any command reaches the wire. The edge itself never emits it; a duplicate pair that arrives in config.json is accepted and queued by the claim registry instead.
Per-output stats
Section titled “Per-output stats”OutputStats.display_stats carries the live numbers for the manager UI:
| Field | Meaning |
|---|---|
frames_displayed |
Total frames page-flipped since the output started. |
frames_dropped_late |
Frames dropped because they fell more than one frame-period behind the audio clock. |
frames_repeated |
Frames held for an extra vsync because the next decoded frame’s PTS was too far ahead of the audio clock. |
audio_underruns |
ALSA EPIPE recoveries observed by the audio task. |
av_sync_offset_ms |
Signed EMA of the video-vs-audio offset (positive = video late). |
current_resolution |
Negotiated KMS resolution (e.g. "1920x1080"). |
current_refresh_hz |
Negotiated refresh rate. |
pixel_format |
Pixel format on the wire — "XRGB8888" on the CPU / dumb-buffer path, "NV12" / "P010" on the VAAPI / NVDEC / RKMPP zero-copy path. |
decoder_kind |
"cpu", "cpu (hw unavailable)" (operator requested a HW backend the host can’t open), "nvdec", "qsv", "vaapi-zerocopy", or "rkmpp-zerocopy" — what the runtime decoder resolver actually opened on this host. |
video_codec |
"h264" / "hevc". |
audio_codec |
"aac" / "mp2" / "ac3" / "eac3" / "opus" / "none". |
upstream_frame_period_us |
Source frame period measured in the decode task, before the display queue. A large divergence from the presented rate means frames are being shed between decode and panel. |
cadence_drift_absorbed |
Vblank holds that departed from the cadence’s nominal length. Expected and periodic — it is the source-vs-panel crystal difference being paid off a whole vblank at a time (a 33 ppm difference at 25 fps absorbs roughly once every ten minutes). The diagnostic signal is the rate, not the presence. |
cadence_hold_aborted |
Holds abandoned because the driver stopped posting vblanks mid-hold. Non-zero means the panel is not receiving the computed cadence at all. |
cadence_disengaged |
The runaway guard disengaged the cadence. Any non-zero value means the feature failed closed on this output. |
cadence_disengaged_stale |
The cadence was disengaged because the measured rates stopped matching the running scheduler — a panel mode change or a source rate change. An ordinary lifecycle event, expected non-zero on any output that switches sources. |
The manager renders the resolution annotation as display (1920x1080@60Hz) in the per-output table on the flow detail page, plus a green DISPLAY badge in the name column.
Limitations
Section titled “Limitations”- Linux only.
- Connector hotplug is picked up by a 10-second background poll rather than a udev event, so a newly plugged monitor can take ~10 s (plus one ~15 s health tick) to reach the manager’s
display_deviceslist. present_vblank_cadenceis off by default pending fleet validation, and never engages on an output that has anaudio_device— locking video to the panel raster is only sound once audio is resampled to the same clock, which the edge does not do yet.- Multichannel passthrough over HDMI is not supported — multichannel sources are downmixed to stereo on the configured
audio_channel_pair. - Only one display output can hold a connector at a time. Duplicates are legal in
config.json— the edge queues them on its claim registry rather than rejecting them — but the manager’s own preflight refuses the pair with HTTP 422display_device_busywhen you add or edit the output through the UI or REST API. - Closed captions and SCTE-104 cue display are not rendered. The decoded raw video is what reaches the screen.
- HDR signalling is supported (BT.2020 + PQ / HLG) when the panel reports
HDR_OUTPUT_METADATA; non-HDR panels fall back to CPU LUT tonemap with onedisplay_hdr_tonemap_activewarning per flow start. - The auto decoder priority follows the codec matrix — operators can pin a specific backend via
hw_decode. A pinned backend that is missing from this build, or unusable on this host, is not rejected: the output starts on CPU decode and raises a Warningdisplay_hw_decode_unavailable_falling_backcarryingpreference,reasonandfell_back_to: "cpu". The picture stays on screen; watch the events, not the save dialog.
Where to read next
Section titled “Where to read next”- Configuration reference — the full output schema, including the display fields above.
- Edge events and alarms — the full event catalogue including the
displaycategory. - Install an edge node — base install, including the runtime apt packages.