Files
RTSP-Streamer/internal/compositor/compositor.go
Levi Woodard 81193f524c 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
2026-07-29 12:17:20 -06:00

238 lines
7.8 KiB
Go

// Package compositor drives a running sway session to tile mpv windows into a
// grid. The daemon does NOT launch sway; the standard kiosk pattern is for
// sway (started at boot via autologin) to exec the daemon, so SWAYSOCK and
// WAYLAND_DISPLAY are inherited. This package shells out to swaymsg, which is
// always present alongside sway and speaks the sway IPC for us.
package compositor
import (
"context"
"encoding/json"
"fmt"
"os/exec"
)
// Rect is a pixel rectangle on the output. The JSON tags match sway's
// get_tree/get_outputs "rect" objects so it can be decoded directly.
type Rect struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"width"`
H int `json:"height"`
}
// GridRects splits a WxH area into cols*rows cells in row-major order. The
// final column and row absorb any rounding remainder so there are no gaps.
func GridRects(width, height, cols, rows int) []Rect {
rects := make([]Rect, 0, cols*rows)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
x := c * width / cols
y := r * height / rows
// Right/bottom edge computed from the next cell boundary to avoid
// cumulative rounding gaps.
x2 := (c + 1) * width / cols
y2 := (r + 1) * height / rows
if c == cols-1 {
x2 = width
}
if r == rows-1 {
y2 = height
}
rects = append(rects, Rect{X: x, Y: y, W: x2 - x, H: y2 - y})
}
}
return rects
}
// TileRect computes the pixel rectangle for a tile spanning (colspan x rowspan)
// cells starting at (col,row) in a cols x rows base grid over a WxH output.
// Boundaries are computed from cell edges so adjacent tiles meet exactly and
// the far edges reach the full width/height with no rounding gaps.
func TileRect(width, height, cols, rows, col, row, colspan, rowspan int) Rect {
x := col * width / cols
y := row * height / rows
x2 := (col + colspan) * width / cols
y2 := (row + rowspan) * height / rows
if col+colspan >= cols {
x2 = width
}
if row+rowspan >= rows {
y2 = height
}
return Rect{X: x, Y: y, W: x2 - x, H: y2 - y}
}
// Client talks to sway via swaymsg.
type Client struct {
bin string
}
// New returns a compositor client. It verifies swaymsg is on PATH.
func New() (*Client, error) {
bin, err := exec.LookPath("swaymsg")
if err != nil {
return nil, fmt.Errorf("swaymsg not found on PATH: %w", err)
}
return &Client{bin: bin}, nil
}
// run executes a raw sway command string.
func (c *Client) run(ctx context.Context, command string) error {
out, err := exec.CommandContext(ctx, c.bin, command).CombinedOutput()
if err != nil {
return fmt.Errorf("swaymsg %q: %w: %s", command, err, out)
}
return nil
}
// Output describes a connected display from `swaymsg -t get_outputs`.
type Output struct {
Name string `json:"name"`
Active bool `json:"active"`
Focused bool `json:"focused"`
CurrentMode struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"current_mode"`
Rect Rect `json:"-"`
}
// PrimaryOutput returns the focused active output (or the first active one),
// used to auto-detect resolution when the config doesn't pin it.
func (c *Client) PrimaryOutput(ctx context.Context) (*Output, error) {
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_outputs", "-r").Output()
if err != nil {
return nil, fmt.Errorf("get_outputs: %w", err)
}
var outputs []Output
if err := json.Unmarshal(out, &outputs); err != nil {
return nil, err
}
var first *Output
for i := range outputs {
o := &outputs[i]
if !o.Active {
continue
}
if first == nil {
first = o
}
if o.Focused {
return o, nil
}
}
if first == nil {
return nil, fmt.Errorf("no active output found")
}
return first, nil
}
// treeNode is the recursive shape of `swaymsg -t get_tree`.
type treeNode struct {
PID int `json:"pid"`
Name string `json:"name"`
AppID string `json:"app_id"`
Rect Rect `json:"rect"`
Nodes []treeNode `json:"nodes"`
Float []treeNode `json:"floating_nodes"`
}
func (n *treeNode) walk(fn func(*treeNode)) {
fn(n)
for i := range n.Nodes {
n.Nodes[i].walk(fn)
}
for i := range n.Float {
n.Float[i].walk(fn)
}
}
// WindowRects returns the geometry of every mapped window keyed by owning
// pid, from a single get_tree round trip. The daemon checks all tiles against
// one snapshot instead of issuing a swaymsg per window.
func (c *Client) WindowRects(ctx context.Context) (map[int]Rect, error) {
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
if err != nil {
return nil, fmt.Errorf("get_tree: %w", err)
}
var root treeNode
if err := json.Unmarshal(out, &root); err != nil {
return nil, err
}
rects := map[int]Rect{}
root.walk(func(n *treeNode) {
if n.PID != 0 && (n.AppID != "" || n.Name != "") {
rects[n.PID] = n.Rect
}
})
return rects, nil
}
// Place floats and positions the window owned by pid at the given rect. Using
// the pid criterion means we never depend on window titles or app-ids.
func (c *Client) Place(ctx context.Context, pid int, r Rect) error {
cmd := fmt.Sprintf(
"[pid=%d] floating enable, border none, move absolute position %d %d, resize set %d %d",
pid, r.X, r.Y, r.W, r.H,
)
return c.run(ctx, cmd)
}
// PlaceAndRaise positions the window owned by pid and raises it above the
// other floating windows (by focusing it — sway raises the focused float).
// Used for the clock overlay, which must stay on top of the camera tiles even
// after a layout switch remaps windows over it. There is no keyboard on the
// kiosk, so taking focus has no downside.
func (c *Client) PlaceAndRaise(ctx context.Context, pid int, r Rect) error {
cmd := fmt.Sprintf(
"[pid=%d] floating enable, move absolute position %d %d, resize set %d %d, focus",
pid, r.X, r.Y, r.W, r.H,
)
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
// IPC parser splits a comma-joined command list at the top level (it does NOT
// fold the continuation into for_window the way the config-file parser does),
// so a multi-command for_window must be registered one command per call.
// Safe to call repeatedly.
func (c *Client) PrepareForMPV(ctx context.Context) error {
return c.run(ctx, `for_window [app_id="mpv"] floating enable`)
}
// PlaceOnMap installs for_window rules that position and size any window with
// the given title the moment it maps — so a restarting stream lands in its
// cell without a wrong-place flash, before the corrective loop ever runs. The
// title is anchored (^...$) so slot-1 never matches slot-10. Because sway's
// IPC command parser splits on commas at the top level, each rule carries a
// single command and is registered separately (a comma-joined for_window would
// silently drop everything after the first command and run the rest
// immediately against the focused container). Re-registering a rule for the
// same criteria replaces it.
func (c *Client) PlaceOnMap(ctx context.Context, title string, r Rect) error {
crit := fmt.Sprintf(`[title="^%s$"]`, title)
cmds := []string{
fmt.Sprintf("for_window %s floating enable", crit),
fmt.Sprintf("for_window %s move absolute position %d %d", crit, r.X, r.Y),
fmt.Sprintf("for_window %s resize set %d %d", crit, r.W, r.H),
}
for _, cmd := range cmds {
if err := c.run(ctx, cmd); err != nil {
return err
}
}
return nil
}