Skip to content

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.

  • 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.

The runtime apt packages are part of every modern Linux base install:

Terminal window
sudo apt update
sudo apt install libdrm2 libasound2t64 libudev1

On 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.

/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:

Terminal window
sudo usermod -aG video,render,audio <user> # takes effect on next login

Missing 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:

Terminal window
cargo build --release # display feature is on by default
cargo build --release --no-default-features --features tls,webrtc # explicit opt-out

Adding a display output via the manager UI

Section titled “Adding a display output via the manager UI”
  1. Open Admin → Nodes, click the edge, Configure, then the Outputs tab.
  2. + Add Output, pick Display (HDMI / DisplayPort) as the type.
  3. Pick a connector from the Device dropdown — the manager populates it from the HealthPayload.display_devices list the edge advertises on every health tick.
  4. Pick an Audio device (an ALSA id like hw:0,3, plughw:0,3, or default). Leave blank for video-only.
  5. 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.)
  6. 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.

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.

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 via snd_pcm_prepare() and is counted in audio_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.

  • Video: H.264 + H.265. Decode resolves through the codec matrix — hw_decode: "auto" (default) picks VAAPI ≻ NVDEC ≻ QSV ≻ RKMPP ≻ CPU against 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.

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 with drmModeAddFB2, 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-rockchip release, 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.

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-byte hdr_output_metadata blob 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 take ALLOW_MODESET because 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. A display_hdr_tonemap_active Warning 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.

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.

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.

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.

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.

  • 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_devices list.
  • present_vblank_cadence is off by default pending fleet validation, and never engages on an output that has an audio_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 422 display_device_busy when 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 one display_hdr_tonemap_active warning 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 Warning display_hw_decode_unavailable_falling_back carrying preference, reason and fell_back_to: "cpu". The picture stays on screen; watch the events, not the save dialog.