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 as an ARGB8888 overlay plane composed at vblank. Stays on the zero-copy path — no CPU-blit demotion.
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. Sustained drift > 100 ms for 3 s emits a display_av_drift Warning event.

  • Video: H.264 + H.265. Decode resolves through the codec matrixhw_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 flipsdrmModeAtomicCommit(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 overlay stays on the zero-copy video path — there’s no CPU-blit demotion of the underlying video frame when bars are enabled.

Each running display output consumes resource-budget units on the edge:

  • 1080p30 software decode → 275 units (250 video + 5 audio + 20 KMS).
  • 4K60 software decode → ~1025 units. Without HW decode that’s likely to fall behind on most CPUs — the manager surfaces a display_decoder_overload_predicted hint in the validation pane when you save the flow.

The edge also enforces per-connector uniqueness — only one active display output per (device, audio_device) pair. A second one is rejected with display_device_busy.

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_device_unavailable Critical KMS connector vanished mid-flow (cable unplug observed via udev or drmModeGetConnector).
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 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. |

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.
display_device_busy Another active output already claimed this (device, audio_device) pair.
display_decoder_overload_predicted Validation-time hint when 4K60 is requested without HW decode. Does not block save — informational only.

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.
  • One active display output per connector — cross-output uniqueness is enforced via display_device_busy.
  • 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. Backends not compiled into this build, or unsupported on the host, are rejected at output validation.