Per-tile stream quality (high/low), fps cap, latency flags

Cameras carry multiple stream URLs (streams: high/medium/low); each
tile picks one via Quality (auto/high/low). Small tiles can pull the
low substream to cut Pi decode load. discover --enable-rtsp takes a
comma list (high,low), enables each channel, records all enabled ones.
TUI: q cycles a tile quality (shown in the tile); discover enables
high+low. Player: --framedrop + low-delay demuxer flags; player.max_fps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-01 21:55:25 -05:00
parent 780396076d
commit 8d95c944bd
10 changed files with 287 additions and 70 deletions

View File

@@ -80,6 +80,9 @@ type Player struct {
Profile string `yaml:"profile"`
// ExtraArgs are appended verbatim to every mpv invocation.
ExtraArgs []string `yaml:"extra_args,omitempty"`
// MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims
// render/scale load; the bigger decode lever is using substreams.
MaxFPS int `yaml:"max_fps,omitempty"`
// RestartBackoffSeconds is how long to wait before relaunching a stream
// that exited or stalled.
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
@@ -92,12 +95,62 @@ type Camera struct {
ID string `yaml:"id,omitempty"`
// Name is the human label and the key layouts reference. Must be unique.
Name string `yaml:"name"`
// RTSP is the fully-resolved stream URL.
RTSP string `yaml:"rtsp"`
// RTSP is a single fully-resolved stream URL. Kept for backward
// compatibility and manual entries; Streams takes precedence when present.
RTSP string `yaml:"rtsp,omitempty"`
// Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a
// tile can choose per-tile which to pull. Populated by discovery.
Streams map[string]string `yaml:"streams,omitempty"`
// Disabled hides the camera from selection without deleting it.
Disabled bool `yaml:"disabled,omitempty"`
}
// Qualities in preference order, high to low.
var Qualities = []string{"high", "medium", "low"}
// StreamURL returns the URL for the requested quality, falling back sensibly:
// the exact quality, then any lower quality, then any stream at all, then the
// legacy single RTSP field.
func (c Camera) StreamURL(quality string) string {
if len(c.Streams) > 0 {
if quality != "" {
if u := c.Streams[quality]; u != "" {
return u
}
}
// Fall back down the preference list from the requested quality.
start := 0
for i, q := range Qualities {
if q == quality {
start = i
break
}
}
for _, q := range Qualities[start:] {
if u := c.Streams[q]; u != "" {
return u
}
}
for _, q := range Qualities {
if u := c.Streams[q]; u != "" {
return u
}
}
}
return c.RTSP
}
// AvailableQualities lists the qualities this camera actually has, high to low.
func (c Camera) AvailableQualities() []string {
var out []string
for _, q := range Qualities {
if c.Streams[q] != "" {
out = append(out, q)
}
}
return out
}
// MaxTiles caps how many simultaneous streams a layout may show. Decoding
// more than this on a Pi 4 is impractical even with substreams.
const MaxTiles = 16
@@ -125,6 +178,9 @@ type Tile struct {
Row int `yaml:"row"`
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1
// Quality selects which stream to pull for this tile: "high"|"medium"|
// "low". Empty means the camera's best available (see Camera.StreamURL).
Quality string `yaml:"quality,omitempty"`
}
// Span returns the tile's spans with zero values normalized to 1.

View File

@@ -45,6 +45,28 @@ func TestValidateTiles(t *testing.T) {
}
}
func TestCameraStreamURL(t *testing.T) {
cam := Camera{Name: "c", Streams: map[string]string{"high": "H", "low": "L"}}
if got := cam.StreamURL("low"); got != "L" {
t.Errorf(`StreamURL("low") = %q, want "L"`, got)
}
if got := cam.StreamURL("high"); got != "H" {
t.Errorf(`StreamURL("high") = %q, want "H"`, got)
}
// Requested quality missing → fall down the preference list, then any.
if got := cam.StreamURL("medium"); got != "L" {
t.Errorf(`StreamURL("medium") = %q, want fallback "L"`, got)
}
if got := cam.StreamURL(""); got != "H" {
t.Errorf(`StreamURL("") = %q, want best "H"`, got)
}
// Legacy single-URL camera.
legacy := Camera{Name: "old", RTSP: "R"}
if got := legacy.StreamURL("low"); got != "R" {
t.Errorf("legacy StreamURL = %q, want RTSP fallback R", got)
}
}
func TestValidateTilesCap(t *testing.T) {
var tiles []Tile
for i := 0; i < MaxTiles+1; i++ {

View File

@@ -122,13 +122,18 @@ func (d *Daemon) applyLayout(ctx context.Context, name string) error {
continue
}
cam := cfg.CameraByName(tile.Camera)
if cam == nil || cam.Disabled || cam.RTSP == "" {
if cam == nil || cam.Disabled {
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
continue
}
url := cam.StreamURL(tile.Quality)
if url == "" {
d.log.Warn("skipping tile: no stream url", "slot", slot, "camera", tile.Camera, "quality", tile.Quality)
continue
}
cs, rs := tile.Span()
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log)
p := player.New(slot, cam.Name, url, cfg.Player, d.runDir, d.log)
players = append(players, p)
d.wg.Add(1)

View File

@@ -81,11 +81,16 @@ func (p *Player) args() []string {
"--title=" + p.Title(),
"--input-ipc-server=" + p.ipcPath,
"--hwdec=" + p.cfg.HWDec,
// Live-stream hygiene: prefer TCP transport, keep buffers small.
// Live-stream hygiene: TCP transport, no caching, and drop frames when
// behind so latency self-corrects instead of accumulating a backlog.
"--rtsp-transport=tcp",
"--profile=low-latency",
"--cache=no",
"--demuxer-lavf-o=stimeout=5000000",
"--framedrop=decoder+vo",
"--demuxer-lavf-o=stimeout=5000000,fflags=+nobuffer,flags=+low_delay",
}
if p.cfg.MaxFPS > 0 {
args = append(args, fmt.Sprintf("--vf=fps=%d", p.cfg.MaxFPS))
}
if p.cfg.Profile != "" && p.cfg.Profile != "low-latency" {
args = append(args, "--profile="+p.cfg.Profile)

View File

@@ -96,6 +96,8 @@ func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.screen, m.cursor = screenCameraPicker, 0
case "c":
m.clearAt()
case "q": // cycle this tile's stream quality (high/low/auto)
m.cycleQuality()
case "L": // grow wider (toward the right)
m.resizeTile(1, 0)
case "H": // shrink narrower (from the right)
@@ -149,6 +151,36 @@ func (m *model) clearAt() {
}
}
// cycleQuality steps the tile under the cursor through auto → its camera's
// available stream qualities (high/low/...). Small tiles → pick low; big tiles
// → high, to keep the Pi's decode load down.
func (m *model) cycleQuality() {
idx := m.tileIndexAt(m.edCol, m.edRow)
if idx < 0 {
m.setStatus("no tile here — assign a camera first", true)
return
}
t := &m.curLayout().Tiles[idx]
opts := []string{""} // "" = auto (best available)
if cam := m.cfg.CameraByName(t.Camera); cam != nil {
opts = append(opts, cam.AvailableQualities()...)
}
cur := 0
for i, q := range opts {
if q == t.Quality {
cur = i
break
}
}
t.Quality = opts[(cur+1)%len(opts)]
m.dirty = true
label := t.Quality
if label == "" {
label = "auto"
}
m.setStatus("tile quality: "+label, false)
}
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
// keeping it in-bounds and non-overlapping.
func (m *model) resizeTile(dCol, dRow int) {

View File

@@ -7,6 +7,7 @@ package tui
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -95,8 +96,8 @@ type savedMsg struct {
reloaded bool
}
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
// turns on RTSP in Protect for any camera that lacks an enabled channel.
// discoverCmd fetches cameras. `enable` is a comma-separated list of qualities
// (e.g. "high,low") to ensure RTSP-enabled in Protect and record per camera.
func (m *model) discoverCmd(enable string) tea.Cmd {
cfg := m.cfg
return func() tea.Msg {
@@ -119,10 +120,25 @@ func (m *model) discoverCmd(enable string) tea.Cmd {
if err != nil {
return discoverMsg{err: err}
}
var want []string
for _, q := range strings.Split(enable, ",") {
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
want = append(want, q)
}
}
enabled := 0
if enable != "" {
names, _ := cl.EnableMissing(ctx, cams, enable)
enabled = len(names)
for i := range cams {
cam := &cams[i]
for _, q := range want {
target := cam.ChannelByPreference(q)
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
continue
}
if alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID); err == nil {
target.RTSPEnabled, target.RTSPAlias = true, alias
enabled++
}
}
}
return discoverMsg{cams: cams, enabled: enabled}
}
@@ -161,28 +177,35 @@ func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
}
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
added, updated, skipped := 0, 0, 0
for _, cam := range msg.cams {
ch := cam.BestEnabledChannel()
if ch == nil {
for i := range msg.cams {
cam := &msg.cams[i]
streams := map[string]string{}
for _, ch := range cam.Channels {
if ch.RTSPEnabled && ch.RTSPAlias != "" {
streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias)
}
}
if len(streams) == 0 {
skipped++
continue
}
url := cl.StreamURL(ch.RTSPAlias)
if ex := m.cameraByID(cam.ID); ex != nil {
ex.Name, ex.RTSP = cam.Name, url
updated++
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
ex.ID, ex.RTSP = cam.ID, url
updated++
} else {
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
added++
entry := m.cameraByID(cam.ID)
if entry == nil {
entry = m.cfg.CameraByName(cam.Name)
}
if entry == nil {
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{})
entry = &m.cfg.Cameras[len(m.cfg.Cameras)-1]
added++
} else {
updated++
}
entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, ""
}
m.dirty = true
enabledNote := ""
if msg.enabled > 0 {
enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled)
enabledNote = fmt.Sprintf(", enabled %d channels", msg.enabled)
}
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
m.screen = screenCameras
@@ -229,7 +252,7 @@ var menuItems = []string{
menuLayouts: "Layouts",
menuSetActive: "Set active layout",
menuDiscover: "Discover from UniFi Protect",
menuDiscoverEnable: "Discover + enable RTSP (high)",
menuDiscoverEnable: "Discover + enable RTSP (hi+lo)",
menuSave: "Save",
menuQuit: "Quit",
}
@@ -252,8 +275,8 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.setStatus("discovering…", false)
return m, m.discoverCmd("")
case menuDiscoverEnable:
m.setStatus("discovering and enabling RTSP…", false)
return m, m.discoverCmd("high")
m.setStatus("discovering and enabling RTSP (high+low)…", false)
return m, m.discoverCmd("high,low")
case menuSave:
return m, m.save()
case menuQuit:

View File

@@ -107,13 +107,27 @@ func (m *model) viewLayoutEdit() string {
b.WriteString("\n\n")
b.WriteString(strings.Join([]string{
hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)",
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear") + " " + hkey("q", "quality"),
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
}, "\n"))
return b.String()
}
// qualityAbbrev shortens a quality name for the compact tile label.
func qualityAbbrev(q string) string {
switch q {
case "high":
return "hi"
case "medium":
return "med"
case "low":
return "lo"
default:
return q
}
}
// renderGrid draws the base grid with tiles, camera labels, continuation marks
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
func (m *model) renderGrid(cols, rows int) string {
@@ -156,9 +170,17 @@ func (m *model) renderCell(l config.Layout, c, r, line int) string {
plain = padTo(truncate(label, cellW), cellW)
case isOrigin && line == 1:
cs, rs := t.Span()
info := ""
if cs > 1 || rs > 1 {
plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW)
info = fmt.Sprintf("%dx%d", cs, rs)
}
if t.Quality != "" {
if info != "" {
info += " "
}
info += qualityAbbrev(t.Quality)
}
plain = padTo(info, cellW)
dim = true
default: // continuation cell of a spanned tile
plain = center("·", cellW)