Fix invisible/flashing clock, map Protect's 8-camera preset, warn on gaps

Three separate faults made cameras "not load" and the clock misbehave.

Clock rendered as nothing, or flashed ~200ms/second. The time was drawn as
an ASS osd-overlay pushed over mpv IPC, but on mpv 0.35 + Mesa/V3D + sway an
OSD overlay is rendered only on the frame where its content *changes*. Every
layer reports success while this happens (mpv returns error:success,
vo-configured is true, and sway reports the window visible at the right
rect), so it looks like a stacking or font bug and is neither. Ruled out:
pushing at 20Hz (identical content is ignored, so it still only redrew when
the second flipped), osd-msg1, show-text, and --pause (mpv stops redrawing
entirely). Fonts were never the issue.

The time is now baked into every frame by a drawtext filter re-reading a
small file the ticker rewrites once a second, with two constraints that cost
real time to find and are pinned by tests:

- The canvas alpha must be > 0. A fully transparent canvas (black@0.0 with
  --alpha=yes) makes the glyphs inherit alpha 0 and the compositor draws
  nothing -- this was the original invisible clock. New clock
  background_opacity (default 0.45) is clamped in config *and* in args() so
  no code path can produce an invisible clock.
- Readahead must be off. drawtext stamps the time when a frame is
  *generated*, so buffering ahead makes the visible clock lag by the
  readahead and swallows text-file updates entirely.

Since the text now arrives through a file, the clock needs no IPC socket:
dropped --input-ipc-server, the ipcPath field, and the stale-socket removal.
assEscape goes with the ASS path.

`views import` produced layouts with holes. viewmap derived the grid from
the slot count alone and ignored Protect's `layout` field, so Protect's
asymmetric 8-camera preset (four 2x2 tiles plus a right column of four 1x1)
landed as 8 tiles in a 3x3 grid -- the bottom-right cell was simply empty
and rendered as a blank rectangle. That preset is now mapped exactly; other
counts keep the uniform GridForSlots fallback rather than guessing at
presets I have not observed. Import also warns when a mapping would leave
empty cells or references a camera missing from the config, so a silent hole
cannot reach the screen again.

Also:

- clock.corner gains bottom-center and top-center (centered horizontally,
  Margin still applies vertically).
- placeClock no longer re-issues `resize set` every tick. Re-asserting
  geometry on a correctly-sized window makes sway send a configure event,
  which makes mpv reallocate buffers and blank for a frame. New
  compositor.Raise re-asserts z-order only, which is all the 2s tick needs;
  geometry is re-placed only when it has actually drifted.
- README documents why the clock is drawn this way, the preset table and how
  to add another from `views dump`, and three troubleshooting entries for
  failure modes that all look like bugs: a blank tile whose mpv is running
  (a stale camera entry -- re-adopting a camera in Protect assigns a new id
  and often a slightly different name, and `discover` never prunes), cameras
  in `cameras:` not being on screen (only the active layout's tiles stream),
  and black bars inside tiles (non-16:9 grid cells; --panscan=1.0 crops to
  fill instead).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYhTnkp7VzJ67THeicgfAQ
This commit is contained in:
Levi Woodard
2026-07-29 12:17:20 -06:00
parent caed091dc2
commit 81193f524c
10 changed files with 562 additions and 80 deletions

View File

@@ -157,24 +157,53 @@ saving an unrelated edit never blanks the screen.
## Clock overlay
An optional always-on-top clock can be rendered in a screen corner:
An optional always-on-top clock can be rendered at a screen edge:
```yaml
clock:
enabled: true
timezone: America/Denver # IANA name; "Local" uses the system zone. DST auto.
format: "15:04:05" # Go time layout (24-hour w/ seconds)
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
corner: bottom-center # bottom-right | bottom-left | top-right | top-left
# | bottom-center | top-center
background_opacity: 0.45 # alpha of the backing box; must be > 0 (see below)
```
It's a tiny transparent mpv window (no extra dependencies) drawing the time as
**white text with a black outline**, so it stays readable over both bright (day)
and dark (night) camera scenes without measuring the picture. The time is
formatted in the configured timezone via Go's zone database, so it's correct
regardless of the host clock's zone and handles DST on its own. The daemon keeps
it positioned and on top across layout switches. Size (`width`/`height`),
`font_size`, and edge `margin` are configurable; enable/disable or retune it
live with an edit plus `rtsp-streamer reload`.
It's a tiny mpv window (no extra dependencies) drawing the time as **white text
with a black outline** over a dimmed backing box, so it stays readable over both
bright (day) and dark (night) camera scenes without measuring the picture. The
time is formatted in the configured timezone via Go's zone database, so it's
correct regardless of the host clock's zone and handles DST on its own. The
daemon keeps it positioned and on top across layout switches. Size
(`width`/`height`), `font_size`, and edge `margin` are configurable;
enable/disable or retune it live with an edit plus `rtsp-streamer reload`.
The `*-center` positions center the window horizontally and ignore `margin` on
that axis; `margin` still applies vertically.
### Why the clock is drawn the way it is
The time is baked into every video frame by an ffmpeg `drawtext` filter that
re-reads a small text file (`$XDG_RUNTIME_DIR/rtsp-streamer/clock-text.txt`),
which the daemon rewrites once a second. That looks roundabout — pushing an ASS
`osd-overlay` over mpv's IPC would be the obvious approach — but on mpv 0.35 +
Mesa/V3D + sway an OSD overlay is rendered **only on the frame where its content
changes**, so an IPC-driven clock is visible for roughly 200ms per second and
reads as a flashing clock. Every layer reports success while this happens
(`error: success` from mpv, `vo-configured: true`, sway reporting the window
visible at the right rect), so it looks like a stacking or font bug and is
neither. Two constraints follow, and both are covered by tests:
- **`background_opacity` must be greater than zero.** A fully transparent canvas
(`color=c=black@0.0` with `--alpha=yes`) makes the drawn glyphs inherit alpha
0, and the compositor shows nothing at all. Values of `0` or below — including
the field being absent — are clamped to the default.
- **The canvas must not be buffered ahead** (`--cache=no`,
`--demuxer-readahead-secs=0`). `drawtext` stamps the time when a frame is
*generated*, so reading ahead makes the displayed clock lag by the readahead
and swallows text-file updates entirely.
Because the text arrives through a file, the clock window needs no IPC socket.
## UniFi Protect live views
@@ -187,10 +216,29 @@ rtsp-streamer views import --all # import every view
rtsp-streamer views dump # raw view JSON (for tuning odd layouts)
```
Cameras are matched by Protect id, so run `discover` first. Grid size is
inferred from the slot count (asymmetric "1 big + N" presets land as an even
grid for now — send me `views dump` output to map exact sizing). An imported
layout is **linked** to its view (`protect_view:` in the config).
Cameras are matched by Protect id, so run `discover` first. An imported layout is
**linked** to its view (`protect_view:` in the config).
Protect's arrangement for a view is not always an even grid. Known asymmetric
presets are mapped exactly; any other slot count falls back to a near-square grid
of equal tiles:
| Slots | Result |
| --- | --- |
| 8 | `5x4` — four 2x2 tiles plus a right-hand column of four 1x1 tiles |
| anything else | near-square uniform grid (`GridForSlots`) |
To add another preset, run `views dump`, note the view's `layout` value and slot
order, and add an entry to `presets` in `internal/viewmap/viewmap.go`. Slot order
in the dump is the order tiles are filled.
Import **warns** whenever a mapping would leave grid cells empty (they show as
blank rectangles on the wall) or references a camera that isn't in the config:
```
! Office View: 1 of 9 cells in the 3x3 grid are empty and will show as blank areas
! Office View: slot 4 camera 6a65…c6d not in config (run `discover`)
```
### Auto-resync
@@ -321,6 +369,26 @@ pegged, work through:
- **Blank/black tile** — verify the stream directly:
`mpv --rtsp-transport=tcp 'rtsps://HOST:7441/ALIAS?enableSrtp'`. If it fails,
RTSP isn't enabled for that camera (`discover --enable-rtsp=high,low`).
- **A tile stays blank but its mpv process is running** — the camera entry is
stale. Re-adopting a camera in Protect gives it a **new id and often a slightly
different name** (`Livingroom` → `Living Room`), and `discover` only adds and
updates, it never prunes — so the old entry lingers with an alias the controller
no longer serves. Process counts look healthy because mpv is up and retrying.
Diff your config's aliases against the controller:
`curl -sk -b cookies.txt https://HOST/proxy/protect/api/bootstrap` and compare
each camera's `channels[].rtspAlias`. Delete the dead entries, then fix every
layout that referenced them (watch for a camera ending up twice after a rename).
- **Cameras in `cameras:` aren't on screen** — only cameras placed as tiles in the
**active layout** are streamed, one mpv per tile. The `cameras:` list is just an
inventory. Check with `rtsp-streamer layout ls` (it prints each layout's tile
count).
- **Black bars inside every tile** — the base grid's cells aren't 16:9, so mpv
letterboxes each feed to fit. On a 16:9 output only a *square* grid (`NxN`)
yields 16:9 cells; e.g. a `5x4` grid on 3840x2160 gives 768x540 (1.42:1) cells.
Some arrangements can't avoid this at all — two 16:9 tiles 1080px tall need the
full 3840px width, leaving no room for a side column. To fill instead of pad,
crop with `player.extra_args: ["--panscan=1.0"]`, at the cost of roughly the top
and bottom 20% of each camera's field of view.
- **A tile's video is cut off / overflows** — should not happen (mpv is told
`--keepaspect-window=no` and the daemon re-squares tiles every second); if it
does, confirm `rtsp-streamer version` is a recent build.

View File

@@ -26,7 +26,11 @@ player:
# interval (staggered) so latency can't slowly
# drift; 0 = off. 600 keeps drift to a few sec.
restart_backoff_seconds: 3
extra_args: []
extra_args: [] # appended verbatim to every stream's mpv.
# ["--panscan=1.0"] crops each feed to fill its
# tile instead of letterboxing it — useful when
# the grid's cells aren't 16:9, at the cost of the
# top/bottom of the field of view.
# Camera catalog. Normally populated by `rtsp-streamer discover`; shown here
# by hand for illustration. Each camera can carry multiple stream qualities
@@ -94,10 +98,17 @@ clock:
format: "15:04:05" # Go time layout (24-hour w/ seconds). Others:
# "3:04:05 PM" · "Mon Jan 2 15:04"
corner: bottom-right # bottom-right | bottom-left | top-right | top-left
# | bottom-center | top-center
# the *-center positions center horizontally and
# ignore margin on that axis
font_size: 44 # glyph height in px
width: 300 # overlay window size in px
height: 72
margin: 24 # gap from the screen edges in px
background_opacity: 0.45 # alpha of the backing box behind the text.
# MUST be > 0: on a fully transparent canvas the
# glyphs inherit alpha 0 and nothing is drawn at
# all. 0/absent is clamped to this default.
# Re-sync the active layout from its linked UniFi Protect live view every N
# seconds (0 = off). Layouts created by `views import` carry a `protect_view:`

View File

@@ -192,6 +192,15 @@ func (c *Client) PlaceAndRaise(ctx context.Context, pid int, r Rect) error {
return c.run(ctx, cmd)
}
// Raise brings pid's window above the other floating windows without touching
// its geometry. Re-issuing `resize set` on a correctly-sized window makes sway
// send a configure event, which makes mpv reallocate its buffers and blank for
// a frame — visible as a periodic flash on a small overlay like the clock. So
// callers that only need z-order must use this, not PlaceAndRaise.
func (c *Client) Raise(ctx context.Context, pid int) error {
return c.run(ctx, fmt.Sprintf("[pid=%d] focus", pid))
}
// PrepareForMPV installs a global rule so every mpv window maps floating,
// ready for the daemon to position. Borders are already off via the kiosk
// config's `default_border none`, so this rule is a single command: sway's

View File

@@ -59,7 +59,8 @@ type Clock struct {
// Examples: "3:04:05 PM", "Mon Jan 2 15:04".
Format string `yaml:"format,omitempty"`
// Corner places the overlay: bottom-right (default), bottom-left,
// top-right, top-left.
// top-right, top-left, bottom-center, top-center. The *-center positions
// center the overlay horizontally and ignore Margin on that axis.
Corner string `yaml:"corner,omitempty"`
// FontSize is the glyph height in pixels (default 44).
FontSize int `yaml:"font_size,omitempty"`
@@ -68,6 +69,15 @@ type Clock struct {
Height int `yaml:"height,omitempty"`
// Margin is the gap from the screen edges in pixels (default 24).
Margin int `yaml:"margin,omitempty"`
// BackgroundOpacity is the alpha of the overlay's backing box, 0.0
// (invisible) to 1.0 (solid black). Default 0.45.
//
// It must not be 0: the time is drawn into the canvas by a drawtext filter,
// and on a fully transparent canvas the glyphs inherit the canvas's zero
// alpha, so the compositor draws nothing and the clock silently disappears.
// A small non-zero value gives the text a dark backing that also keeps it
// legible over bright daytime scenes.
BackgroundOpacity float64 `yaml:"background_opacity,omitempty"`
}
// Controller holds UniFi Protect connection details.
@@ -355,6 +365,14 @@ func (c *Config) Defaults() {
if c.Clock.Margin == 0 {
c.Clock.Margin = 24
}
if c.Clock.BackgroundOpacity <= 0 {
// 0 renders an invisible clock (see BackgroundOpacity), so treat
// unset — and any nonsense value — as the default.
c.Clock.BackgroundOpacity = 0.45
}
if c.Clock.BackgroundOpacity > 1 {
c.Clock.BackgroundOpacity = 1
}
}
}
@@ -407,9 +425,10 @@ func (c *Config) Validate() error {
if c.Clock.Enabled && c.Clock.Corner != "" {
switch c.Clock.Corner {
case "bottom-right", "bottom-left", "top-right", "top-left":
case "bottom-right", "bottom-left", "top-right", "top-left",
"bottom-center", "top-center":
default:
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left", c.Clock.Corner)
return fmt.Errorf("clock.corner %q must be one of bottom-right, bottom-left, top-right, top-left, bottom-center, top-center", c.Clock.Corner)
}
}
return nil

View File

@@ -15,6 +15,10 @@ func TestClockRectCorners(t *testing.T) {
"top-right": {1920 - 300 - 24, 24},
"top-left": {24, 24},
"": {1920 - 300 - 24, 1080 - 72 - 24}, // default = bottom-right
// Centered positions ignore the horizontal margin and center on the
// output; the margin still applies vertically.
"bottom-center": {(1920 - 300) / 2, 1080 - 72 - 24},
"top-center": {(1920 - 300) / 2, 24},
}
for corner, want := range cases {
cl.Corner = corner

View File

@@ -170,13 +170,29 @@ func (d *Daemon) placeClock(ctx context.Context, clock *player.Clock, rect compo
if pid == 0 {
continue
}
// Only re-assert geometry when it has actually drifted. Sending
// `resize set` every tick makes sway reconfigure the surface, which
// blanks mpv for a frame and reads as a flashing clock. Z-order still
// needs re-asserting every tick, because a restarted camera maps
// focused and lands above the overlay — but a bare focus does not
// touch the surface.
if rects, err := d.comp.WindowRects(ctx); err == nil {
if actual, ok := rects[pid]; ok && actual == rect {
if err := d.comp.Raise(ctx, pid); err != nil {
d.log.Debug("clock raise failed", "err", err)
}
continue
}
}
if err := d.comp.PlaceAndRaise(ctx, pid, rect); err != nil {
d.log.Debug("clock place failed", "err", err)
}
}
}
// clockRect computes the overlay rectangle for the configured corner.
// clockRect computes the overlay rectangle for the configured corner. The
// *-center positions are horizontally centred on the output, which is why the
// setting is a position rather than strictly a corner.
func clockRect(w, h int, cl config.Clock) compositor.Rect {
m, cw, ch := cl.Margin, cl.Width, cl.Height
x, y := w-cw-m, h-ch-m // bottom-right default
@@ -187,6 +203,10 @@ func clockRect(w, h int, cl config.Clock) compositor.Rect {
x, y = m, m
case "top-right":
x, y = w-cw-m, m
case "bottom-center":
x, y = (w-cw)/2, h-ch-m
case "top-center":
x, y = (w-cw)/2, m
}
return compositor.Rect{X: x, Y: y, W: cw, H: ch}
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -15,17 +16,26 @@ import (
)
// Clock is a small always-on-top mpv window that renders the current local
// time. The window plays a fully transparent lavfi canvas (so the camera video
// shows through) and the time is drawn as an ASS overlay pushed over mpv's IPC
// once a second: white glyphs with a black outline, which stay legible over
// both bright (day) and dark (night) scenes without sampling the picture. The
// time is formatted in Go against a fixed timezone, so it does not depend on
// the host clock's zone and gets DST right.
// time: white glyphs with a black outline, which stay legible over both bright
// (day) and dark (night) scenes without sampling the picture. The time is
// formatted in Go against a fixed timezone, so it does not depend on the host
// clock's zone and gets DST right.
//
// The text is baked into the canvas by an ffmpeg drawtext filter reading
// textPath, which the ticker rewrites once a second (drawtext's reload=1 re-reads
// the file every frame). It is deliberately NOT drawn as an mpv OSD overlay:
// on mpv 0.35 + Mesa/V3D + sway, osd-overlay renders only on the single frame
// where its content changes, so an IPC-pushed clock appears for ~200ms a second
// and reads as a flashing clock. Baking the text into every frame is stable.
//
// Because the text is rendered when a frame is *generated*, the canvas must not
// be buffered ahead of display or the visible time lags — see args() for the
// cache flags that keep generation just-in-time.
type Clock struct {
cfg config.Clock
loc *time.Location
runDir string
ipcPath string
textPath string
log *slog.Logger
mu sync.Mutex
@@ -39,13 +49,35 @@ func NewClock(cfg config.Clock, runDir string, log *slog.Logger) (*Clock, error)
if err != nil {
return nil, fmt.Errorf("clock timezone %q: %w", cfg.Timezone, err)
}
return &Clock{
textPath := filepath.Join(runDir, "clock-text.txt")
// A ':' or '\' in the path would be read as filtergraph syntax and break the
// drawtext option rather than pointing at the file.
if strings.ContainsAny(textPath, `:\`) {
return nil, fmt.Errorf("clock text path %q contains a character that cannot be escaped in a filtergraph", textPath)
}
c := &Clock{
cfg: cfg,
loc: loc,
runDir: runDir,
ipcPath: filepath.Join(runDir, "mpv-clock.sock"),
textPath: textPath,
log: log.With("comp", "clock"),
}, nil
}
// drawtext fails to initialise if the file is missing, which would take the
// whole window down, so seed it before mpv ever starts.
if err := c.writeText(); err != nil {
return nil, fmt.Errorf("seeding clock text: %w", err)
}
return c, nil
}
// writeText renders the current time and replaces textPath atomically, so
// drawtext never reads a half-written file.
func (c *Clock) writeText() error {
tmp := c.textPath + ".tmp"
if err := os.WriteFile(tmp, []byte(time.Now().In(c.loc).Format(c.cfg.Format)), 0o644); err != nil {
return err
}
return os.Rename(tmp, c.textPath)
}
// Title is the window title, so the compositor can match the clock by title.
@@ -62,12 +94,31 @@ func (c *Clock) PID() int {
}
func (c *Clock) args() []string {
// A transparent RGBA canvas at a few fps; the text is drawn via osd-overlay,
// so nothing needs to be escaped into the filtergraph. --alpha=yes lets the
// transparent areas composite over the camera windows behind it (if the
// compositor can't do alpha, the canvas is black and the outlined white
// text is still perfectly readable — it just gains a dark backing).
src := fmt.Sprintf("av://lavfi:color=c=black@0.0:s=%dx%d:r=4,format=rgba", c.cfg.Width, c.cfg.Height)
// A mostly-transparent RGBA canvas at a few fps; the text is drawn via
// osd-overlay, so nothing needs to be escaped into the filtergraph.
// --alpha=yes lets the canvas composite over the camera windows behind it.
//
// The canvas alpha must stay > 0. mpv blends the OSD into the canvas, and
// on a fully transparent one the text inherits alpha 0, so the compositor
// draws nothing at all and the clock silently vanishes (observed on
// Mesa/V3D + sway). BackgroundOpacity is clamped to a non-zero default in
// config; the dark backing it produces also keeps the white text legible
// against bright daytime scenes.
// Clamped here too, not just in config, so a Clock built by any other path
// still cannot render itself invisible.
opacity := c.cfg.BackgroundOpacity
if opacity <= 0 {
opacity = 0.45
} else if opacity > 1 {
opacity = 1
}
// borderw draws the black outline that keeps white glyphs readable against a
// bright scene; the text is centred on the canvas.
src := fmt.Sprintf(
"av://lavfi:color=c=black@%.3f:s=%dx%d:r=4,format=rgba,"+
"drawtext=textfile=%s:reload=1:fontsize=%d:fontcolor=white:"+
"borderw=3:bordercolor=black:x=(w-text_w)/2:y=(h-text_h)/2",
opacity, c.cfg.Width, c.cfg.Height, c.textPath, c.cfg.FontSize)
return []string{
"--no-config",
"--force-window=yes",
@@ -83,15 +134,23 @@ func (c *Clock) args() []string {
"--no-audio",
"--keepaspect=no",
"--alpha=yes",
// drawtext stamps the time when a frame is GENERATED, so any readahead
// shows a stale clock (buffering a few seconds ahead made the displayed
// time lag by that much and swallowed text updates entirely). Keep
// generation just-in-time.
"--cache=no",
"--demuxer-readahead-secs=0",
"--demuxer-max-bytes=64KiB",
"--profile=low-latency",
"--title=" + c.Title(),
"--input-ipc-server=" + c.ipcPath,
fmt.Sprintf("--geometry=%dx%d", c.cfg.Width, c.cfg.Height),
src,
}
}
// start launches the clock mpv. It needs no IPC socket: the time reaches the
// window through textPath, which drawtext re-reads every frame.
func (c *Clock) start(ctx context.Context) error {
_ = os.Remove(c.ipcPath)
cmd := exec.CommandContext(ctx, "mpv", c.args()...)
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
@@ -162,30 +221,11 @@ func (c *Clock) tick(ctx context.Context, done <-chan struct{}) {
}
}
// push renders the current time as ASS and sends it as an OSD overlay. an5
// centers it in the window; \bord gives the black outline, \1c/\3c set the
// white fill and black outline colors (ASS is &HBBGGRR&).
// push publishes the current time for the drawtext filter to pick up on its
// next frame. Failures are logged at warn, not debug: a clock that stops
// updating is silently wrong, which is worse than one that is visibly absent.
func (c *Clock) push() {
text := assEscape(time.Now().In(c.loc).Format(c.cfg.Format))
data := fmt.Sprintf(
`{\an5\fs%d\bord3\shad1\1c&HFFFFFF&\3c&H000000&\4c&H000000&\b1}%s`,
c.cfg.FontSize, text,
)
if _, err := ipcCommand(c.ipcPath, "osd-overlay", 1, "ass-events", data, 0, c.cfg.Height, 0, false, false); err != nil {
c.log.Debug("clock overlay update failed", "err", err)
if err := c.writeText(); err != nil {
c.log.Warn("clock text update failed", "path", c.textPath, "err", err)
}
}
// assEscape drops the few characters that are special in ASS override text, so
// an unusual time format string can't break rendering. These never appear in a
// rendered time, so dropping them is harmless.
func assEscape(s string) string {
r := make([]rune, 0, len(s))
for _, ch := range s {
if ch == '{' || ch == '}' || ch == '\\' {
continue
}
r = append(r, ch)
}
return string(r)
}

View File

@@ -2,8 +2,13 @@ package player
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
)
@@ -29,3 +34,90 @@ func TestLastLineNil(t *testing.T) {
t.Errorf("lastLine(nil) = %q, want empty", got)
}
}
func testClock(cfg config.Clock) *Clock {
return &Clock{cfg: cfg, textPath: "/run/user/1000/rtsp-streamer/clock-text.txt"}
}
// A zero-alpha canvas makes the drawn text inherit alpha 0, so the clock
// renders as nothing at all. Config clamps the opacity, and args() must clamp
// too rather than emitting a fully transparent canvas.
func TestClockCanvasIsNeverFullyTransparent(t *testing.T) {
for _, opacity := range []float64{0, -1, 0.45, 1} {
c := testClock(config.Clock{Width: 300, Height: 72, BackgroundOpacity: opacity})
src := c.args()[len(c.args())-1]
if strings.Contains(src, "black@0.000") {
t.Errorf("opacity %v produced a fully transparent canvas: %s", opacity, src)
}
if !strings.Contains(src, "s=300x72") {
t.Errorf("opacity %v: size missing from %s", opacity, src)
}
}
}
// The configured opacity must reach the filtergraph verbatim.
func TestClockCanvasUsesConfiguredOpacity(t *testing.T) {
src := testClock(config.Clock{Width: 300, Height: 72, BackgroundOpacity: 0.6}).args()
if got := src[len(src)-1]; !strings.Contains(got, "black@0.600") {
t.Errorf("got %s, want canvas alpha 0.600", got)
}
}
// The time must be baked into every frame by drawtext, reloading the text file
// each frame. An mpv OSD overlay only renders on the frame where its content
// changes, which showed up as a flashing clock.
func TestClockDrawsTextIntoEveryFrame(t *testing.T) {
c := testClock(config.Clock{Width: 300, Height: 72, FontSize: 44})
src := c.args()[len(c.args())-1]
for _, want := range []string{
"drawtext=textfile=" + c.textPath,
"reload=1", // re-read the file per frame, else the clock freezes
"fontsize=44", // from config, not hardcoded
"borderw=3", // outline for legibility over bright scenes
} {
if !strings.Contains(src, want) {
t.Errorf("filtergraph missing %q:\n %s", want, src)
}
}
}
// drawtext stamps the time when a frame is generated, so buffering ahead makes
// the visible clock lag. These flags keep generation just-in-time.
func TestClockDisablesReadahead(t *testing.T) {
args := testClock(config.Clock{Width: 300, Height: 72, FontSize: 44}).args()
joined := strings.Join(args, " ")
for _, want := range []string{"--cache=no", "--demuxer-readahead-secs=0"} {
if !strings.Contains(joined, want) {
t.Errorf("args missing %q", want)
}
}
}
// writeText must replace the file atomically so drawtext cannot read a
// half-written time, and must honour the configured layout and zone.
func TestClockWriteTextIsAtomicAndFormatted(t *testing.T) {
dir := t.TempDir()
loc, err := time.LoadLocation("America/Denver")
if err != nil {
t.Fatal(err)
}
c := &Clock{
cfg: config.Clock{Format: "15:04:05"},
loc: loc,
textPath: filepath.Join(dir, "clock-text.txt"),
}
if err := c.writeText(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(c.textPath)
if err != nil {
t.Fatal(err)
}
if got := string(b); len(got) != len("15:04:05") {
t.Errorf("wrote %q, want an HH:MM:SS-shaped time", got)
}
// The temp file used for the atomic rename must not be left behind.
if _, err := os.Stat(c.textPath + ".tmp"); !os.IsNotExist(err) {
t.Errorf("temp file left behind: %v", err)
}
}

View File

@@ -31,18 +31,66 @@ func GridForSlots(n int) (cols, rows int) {
}
}
// LayoutFromView maps a Protect live view to a layout. Cameras are placed
// row-major, one per slot (the first camera of a cycling slot). Sizing uses a
// slot-count grid for now; asymmetric Protect presets need the `layout` int
// mapping. The returned layout is linked back to the view (ProtectView) so the
// daemon can re-sync it. Returns warnings for unmappable cameras.
// preset is a Protect arrangement: a base grid plus one cell placement per
// slot, in slot order. Camera is filled in by LayoutFromView.
type preset struct {
cols, rows int
cells []config.Tile
}
// presets holds the Protect arrangements that are NOT a uniform grid, keyed by
// slot count. Protect picks these presets by camera count, so a count that is
// absent here is a plain row-major grid and falls through to uniformPreset.
var presets = map[int]preset{
// Protect's 8-camera preset: four 2x2 tiles with a right-hand column of
// four 1x1 tiles. Numbers are slot order:
// 1 1 2 2 3
// 1 1 2 2 4
// 5 5 6 6 7
// 5 5 6 6 8
8: {cols: 5, rows: 4, cells: []config.Tile{
{Col: 0, Row: 0, ColSpan: 2, RowSpan: 2},
{Col: 2, Row: 0, ColSpan: 2, RowSpan: 2},
{Col: 4, Row: 0, ColSpan: 1, RowSpan: 1},
{Col: 4, Row: 1, ColSpan: 1, RowSpan: 1},
{Col: 0, Row: 2, ColSpan: 2, RowSpan: 2},
{Col: 2, Row: 2, ColSpan: 2, RowSpan: 2},
{Col: 4, Row: 2, ColSpan: 1, RowSpan: 1},
{Col: 4, Row: 3, ColSpan: 1, RowSpan: 1},
}},
}
// uniformPreset builds a plain row-major grid of 1x1 cells.
func uniformPreset(cols, rows int) preset {
p := preset{cols: cols, rows: rows}
for i := 0; i < cols*rows; i++ {
p.cells = append(p.cells, config.Tile{Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
}
return p
}
// presetForSlots returns the arrangement Protect uses for n slots.
func presetForSlots(n int) preset {
if p, ok := presets[n]; ok {
return p
}
cols, rows := GridForSlots(n)
return uniformPreset(cols, rows)
}
// LayoutFromView maps a Protect live view to a layout. Each slot's first camera
// (slots may cycle several) is placed into that slot's cell in the arrangement
// Protect uses for the slot count — see presets. The returned layout is linked
// back to the view (ProtectView) so the daemon can re-sync it. Returns warnings
// for unmappable cameras and for any arrangement that leaves the wall with
// empty cells, which would otherwise show as blank rectangles on screen.
func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layout, []string) {
cols, rows := GridForSlots(len(v.Slots))
p := presetForSlots(len(v.Slots))
var tiles []config.Tile
var warns []string
for i, slot := range v.Slots {
if i >= cols*rows {
warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", cols, rows))
if i >= len(p.cells) {
warns = append(warns, fmt.Sprintf("more slots than the %dx%d grid holds; extra dropped", p.cols, p.rows))
break
}
if len(slot.Cameras) == 0 {
@@ -50,19 +98,51 @@ func LayoutFromView(v protect.LiveView, idToName map[string]string) (config.Layo
}
name := idToName[slot.Cameras[0]]
if name == "" {
warns = append(warns, fmt.Sprintf("slot %d camera %s not in config", i, slot.Cameras[0]))
warns = append(warns, fmt.Sprintf("slot %d camera %s not in config (run `discover`)", i, slot.Cameras[0]))
continue
}
tiles = append(tiles, config.Tile{Camera: name, Col: i % cols, Row: i / cols, ColSpan: 1, RowSpan: 1})
t := p.cells[i]
t.Camera = name
tiles = append(tiles, t)
}
if gaps := unfilled(p, tiles); gaps > 0 {
warns = append(warns, fmt.Sprintf("%d of %d cells in the %dx%d grid are empty and will show as blank areas",
gaps, p.cols*p.rows, p.cols, p.rows))
}
return config.Layout{
Name: Sanitize(v.Name),
Grid: fmt.Sprintf("%dx%d", cols, rows),
Grid: fmt.Sprintf("%dx%d", p.cols, p.rows),
Tiles: tiles,
ProtectView: v.Name,
}, warns
}
// unfilled counts grid cells no tile covers. A non-zero result means the wall
// has holes: either the preset does not tile its own grid or slots were skipped.
func unfilled(p preset, tiles []config.Tile) int {
if p.cols <= 0 || p.rows <= 0 {
return 0
}
covered := make([]bool, p.cols*p.rows)
for _, t := range tiles {
cs, rs := t.Span()
for r := t.Row; r < t.Row+rs; r++ {
for c := t.Col; c < t.Col+cs; c++ {
if r >= 0 && r < p.rows && c >= 0 && c < p.cols {
covered[r*p.cols+c] = true
}
}
}
}
n := 0
for _, ok := range covered {
if !ok {
n++
}
}
return n
}
// Sanitize turns a view name into a layout name (lowercase, dashed).
func Sanitize(s string) string {
s = strings.ToLower(strings.TrimSpace(s))

View File

@@ -0,0 +1,139 @@
package viewmap
import (
"strings"
"testing"
"github.com/lwoodard/rtsp-streamer/internal/protect"
)
// view builds a live view whose slots each hold one camera id.
func view(name string, ids ...string) protect.LiveView {
v := protect.LiveView{Name: name}
for _, id := range ids {
v.Slots = append(v.Slots, protect.LiveViewSlot{Cameras: []string{id}})
}
return v
}
// namesFor maps ids "c0".."cN-1" to themselves so tiles are easy to assert on.
func namesFor(n int) map[string]string {
m := map[string]string{}
for i := 0; i < n; i++ {
m[id(i)] = id(i)
}
return m
}
func id(i int) string { return string(rune('a' + i)) }
func ids(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = id(i)
}
return out
}
// The 8-camera Protect preset is asymmetric: four 2x2 tiles plus a right-hand
// column of four 1x1 tiles, tiling a 5x4 grid exactly.
func TestLayoutFromView_EightSlotPreset(t *testing.T) {
got, warns := LayoutFromView(view("Office View", ids(8)...), namesFor(8))
if got.Grid != "5x4" {
t.Fatalf("grid = %q, want 5x4", got.Grid)
}
if len(warns) != 0 {
t.Errorf("unexpected warnings: %v", warns)
}
if got.ProtectView != "Office View" || got.Name != "office-view" {
t.Errorf("name/link = %q/%q", got.Name, got.ProtectView)
}
want := []struct{ col, row, cs, rs int }{
{0, 0, 2, 2}, {2, 0, 2, 2}, {4, 0, 1, 1}, {4, 1, 1, 1},
{0, 2, 2, 2}, {2, 2, 2, 2}, {4, 2, 1, 1}, {4, 3, 1, 1},
}
if len(got.Tiles) != len(want) {
t.Fatalf("got %d tiles, want %d", len(got.Tiles), len(want))
}
for i, w := range want {
tile := got.Tiles[i]
cs, rs := tile.Span()
if tile.Col != w.col || tile.Row != w.row || cs != w.cs || rs != w.rs {
t.Errorf("tile %d = (%d,%d)+%dx%d, want (%d,%d)+%dx%d",
i, tile.Col, tile.Row, cs, rs, w.col, w.row, w.cs, w.rs)
}
if tile.Camera != id(i) {
t.Errorf("tile %d camera = %q, want %q", i, tile.Camera, id(i))
}
}
}
// The 8-slot preset must leave no holes; that was the original bug (8 tiles
// dropped into a 3x3 grid left the bottom-right cell blank).
func TestLayoutFromView_EightSlotsTileGridExactly(t *testing.T) {
got, _ := LayoutFromView(view("Office View", ids(8)...), namesFor(8))
if n := unfilled(presetForSlots(8), got.Tiles); n != 0 {
t.Errorf("%d cells left empty, want 0", n)
}
}
// Counts without a preset keep the uniform row-major grid.
func TestLayoutFromView_UniformFallback(t *testing.T) {
got, warns := LayoutFromView(view("Default", ids(9)...), namesFor(9))
if got.Grid != "3x3" {
t.Fatalf("grid = %q, want 3x3", got.Grid)
}
if len(warns) != 0 {
t.Errorf("unexpected warnings: %v", warns)
}
for i, tile := range got.Tiles {
if tile.Col != i%3 || tile.Row != i/3 {
t.Errorf("tile %d at (%d,%d), want (%d,%d)", i, tile.Col, tile.Row, i%3, i/3)
}
}
}
// A count whose uniform grid is larger than the slot count leaves holes, and
// the caller must be told rather than silently shipping a wall with blanks.
func TestLayoutFromView_WarnsOnHoles(t *testing.T) {
got, warns := LayoutFromView(view("Seven", ids(7)...), namesFor(7))
if got.Grid != "3x3" {
t.Fatalf("grid = %q, want 3x3", got.Grid)
}
if !hasWarn(warns, "empty") {
t.Errorf("expected an empty-cell warning, got %v", warns)
}
}
// An unknown camera id is reported and its cell left out.
func TestLayoutFromView_WarnsOnUnknownCamera(t *testing.T) {
got, warns := LayoutFromView(view("Office View", ids(8)...), namesFor(7))
if len(got.Tiles) != 7 {
t.Errorf("got %d tiles, want 7", len(got.Tiles))
}
if !hasWarn(warns, "not in config") {
t.Errorf("expected an unknown-camera warning, got %v", warns)
}
}
func TestUnfilledCountsUncoveredCells(t *testing.T) {
p := uniformPreset(3, 3)
if n := unfilled(p, nil); n != 9 {
t.Errorf("empty layout: got %d uncovered, want 9", n)
}
full, _ := LayoutFromView(view("Default", ids(9)...), namesFor(9))
if n := unfilled(p, full.Tiles); n != 0 {
t.Errorf("full 3x3: got %d uncovered, want 0", n)
}
}
func hasWarn(warns []string, substr string) bool {
for _, w := range warns {
if strings.Contains(w, substr) {
return true
}
}
return false
}