feat: known-good CPU audio + 1080p VAAPI + KMS display wall
Working snapshot before the OpenVINO MetricGAN GPU spike: thread-capped SpeechBrain DelaySum+MetricGAN on CPU, hop 200 ms, video hold for A/V sync, Arc VAAPI H.264, headless kmssink wall. 32 tests passing. Do not treat this as a GPU audio path.
This commit is contained in:
+18
-4
@@ -12,10 +12,15 @@ PARTICIPANT_PREFIX=cam
|
|||||||
# Streams
|
# Streams
|
||||||
# "all" = every discovered camera, "1,3,5" or "0-19" = subset
|
# "all" = every discovered camera, "1,3,5" or "0-19" = subset
|
||||||
CAMERAS=all
|
CAMERAS=all
|
||||||
VIDEO_WIDTH=1280
|
VIDEO_WIDTH=640
|
||||||
VIDEO_HEIGHT=720
|
VIDEO_HEIGHT=360
|
||||||
VIDEO_FPS=30
|
VIDEO_FPS=30
|
||||||
VIDEO_MIN_FPS=5
|
VIDEO_MIN_FPS=5
|
||||||
|
VIDEO_BITRATE=800000
|
||||||
|
VIDEO_CODEC=h264
|
||||||
|
VIDEO_ENCODER=vaapi
|
||||||
|
LIBVA_DRM_DEVICE=/dev/dri/renderD129
|
||||||
|
LIBVA_DRIVER_NAME=iHD
|
||||||
|
|
||||||
# Audio
|
# Audio
|
||||||
AUDIO_SAMPLE_RATE=16000
|
AUDIO_SAMPLE_RATE=16000
|
||||||
@@ -44,9 +49,18 @@ PUBLISH_TIMEOUT_S=60
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Headless display wall (run_displays.py / livekit-displays.service)
|
# Headless display wall (run_displays.py / livekit-displays.service)
|
||||||
# 3 DRM outputs showing LiveKit participants via GStreamer kmssink
|
# 3 DRM roles: 20-cam grid, designated speaker camera, screenshare STUB
|
||||||
DISPLAY_COUNT=3
|
DISPLAY_COUNT=3
|
||||||
DISPLAY_IDENTITIES=cam-01,cam-02,cam-03
|
DISPLAY_ROLES=grid,speaker,screenshare
|
||||||
# Empty = auto (connected heads first, prefer Arc HDMI-A-*)
|
# Empty = auto (connected heads first, prefer Arc HDMI-A-*)
|
||||||
DISPLAY_CONNECTORS=
|
DISPLAY_CONNECTORS=
|
||||||
DISPLAY_IDENTITY=display-wall
|
DISPLAY_IDENTITY=display-wall
|
||||||
|
# Speaker camera pane: cam-NN or "active"
|
||||||
|
DISPLAY_SPEAKER_CAMERA=cam-01
|
||||||
|
DISPLAY_GRID_COLS=5
|
||||||
|
DISPLAY_GRID_ROWS=4
|
||||||
|
DISPLAY_GRID_WIDTH=1920
|
||||||
|
DISPLAY_GRID_HEIGHT=1080
|
||||||
|
DISPLAY_GRID_FPS=30
|
||||||
|
# Screen share is a stub until production LiveKit is online.
|
||||||
|
LIVEKIT_PUBLIC_URL=
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ LiveKit room.
|
|||||||
|
|
||||||
Each camera becomes one LiveKit participant (`cam-01` .. `cam-20`) that
|
Each camera becomes one LiveKit participant (`cam-01` .. `cam-20`) that
|
||||||
publishes:
|
publishes:
|
||||||
- a camera video track (1280x720 @ 30 fps by default, MJPG from V4L2)
|
- a camera video track (640x360 @ 30 fps MJPG, H.264 VAAPI — sized
|
||||||
|
for low-latency wall decode, not max quality)
|
||||||
- a microphone audio track (16 kHz mono, cleaned with speechbrain)
|
- a microphone audio track (16 kHz mono, cleaned with speechbrain)
|
||||||
|
|
||||||
Identity is the USB serial, pinned by udev (`/dev/camNN` + `slots.json`).
|
Identity is the USB serial, pinned by udev (`/dev/camNN` + `slots.json`).
|
||||||
@@ -72,28 +73,31 @@ re-run `--write` unless you intend to remap slots.
|
|||||||
## Headless displays (LiveKit -> GStreamer kmssink)
|
## Headless displays (LiveKit -> GStreamer kmssink)
|
||||||
|
|
||||||
This host has no X11/Wayland. `run_displays.py` joins the LiveKit room as
|
This host has no X11/Wayland. `run_displays.py` joins the LiveKit room as
|
||||||
`display-wall` (subscribe-only) and pipes each camera's video into a
|
`display-wall` (subscribe-only) and drives three DRM roles with GStreamer
|
||||||
GStreamer `kmssink` on a DRM connector.
|
`kmssink`:
|
||||||
|
|
||||||
Default mapping: `cam-01`, `cam-02`, `cam-03` onto the first 3 DRM heads
|
1. **grid** — 5x4 mosaic of cam-01..cam-20
|
||||||
(connected first, Arc `xe` HDMI preferred). Today HDMI-A-7 is the live 4K
|
2. **speaker** — designated speaker camera (`DISPLAY_SPEAKER_CAMERA`, default
|
||||||
panel; plug two more HDMI cables into the Arc card (`HDMI-A-5`/`HDMI-A-6`)
|
`cam-01`; set to `active` to follow LiveKit active speaker)
|
||||||
and they light up on the same process.
|
3. **screenshare** — stub placeholder until the production LiveKit server
|
||||||
|
is online (no subscribe, no speaker login yet)
|
||||||
|
|
||||||
|
Default connector order: connected Arc HDMI first (today HDMI-A-7 is live;
|
||||||
|
HDMI-A-5 / HDMI-A-6 light up when cabled).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python run_displays.py --list
|
.venv/bin/python run_displays.py --list
|
||||||
.venv/bin/python run_displays.py --test-pattern # smpte/ball/snow, no LiveKit
|
.venv/bin/python run_displays.py --test-pattern
|
||||||
.venv/bin/python run_displays.py # subscribe cam-01..03
|
.venv/bin/python run_displays.py
|
||||||
.venv/bin/python run_displays.py --connectors HDMI-A-7,HDMI-A-6,HDMI-A-5
|
.venv/bin/python run_displays.py --speaker-camera cam-07
|
||||||
|
|
||||||
sudo cp deploy/livekit-displays.service /etc/systemd/system/
|
sudo systemctl restart livekit-displays
|
||||||
sudo systemctl daemon-reload
|
journalctl -u livekit-displays -f
|
||||||
sudo systemctl enable --now livekit-displays
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`DISPLAY_IDENTITIES` / `DISPLAY_CONNECTORS` / `DISPLAY_COUNT` in `.env`.
|
`.env`: `DISPLAY_ROLES`, `DISPLAY_CONNECTORS`, `DISPLAY_SPEAKER_CAMERA`,
|
||||||
kmssink takes the DRM master for those connectors (the framebuffer console
|
`DISPLAY_GRID_*`. kmssink takes the DRM master (framebuffer console on
|
||||||
on HDMI-A-7 will be replaced by camera video).
|
HDMI-A-7 is replaced by the grid).
|
||||||
|
|
||||||
## Audio cleanup
|
## Audio cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ class Config:
|
|||||||
room: str = "cameras"
|
room: str = "cameras"
|
||||||
participant_prefix: str = "cam"
|
participant_prefix: str = "cam"
|
||||||
# video
|
# video
|
||||||
width: int = 1280
|
width: int = 640
|
||||||
height: int = 720
|
height: int = 360
|
||||||
fps: int = 30
|
fps: int = 30
|
||||||
min_fps: int = 5
|
min_fps: int = 5
|
||||||
|
video_bitrate: int = 800_000
|
||||||
|
video_codec: str = "h264"
|
||||||
|
video_encoder: str = "vaapi" # auto|software|hardware|nvenc|vaapi
|
||||||
|
vaapi_device: str = "/dev/dri/renderD129"
|
||||||
# audio
|
# audio
|
||||||
audio_rate: int = 16000
|
audio_rate: int = 16000
|
||||||
audio_channels: int = 1
|
audio_channels: int = 1
|
||||||
@@ -46,6 +50,22 @@ class Config:
|
|||||||
publish_timeout_s: float = 60.0
|
publish_timeout_s: float = 60.0
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cameras: list[str] = field(default_factory=lambda: ["all"])
|
cameras: list[str] = field(default_factory=lambda: ["all"])
|
||||||
|
# headless displays (LiveKit subscribe -> GStreamer kmssink)
|
||||||
|
display_count: int = 3
|
||||||
|
display_identities: list[str] = field(
|
||||||
|
default_factory=lambda: ["cam-01", "cam-02", "cam-03"])
|
||||||
|
display_connectors: list[str] = field(default_factory=list)
|
||||||
|
display_identity: str = "display-wall"
|
||||||
|
display_roles: list[str] = field(
|
||||||
|
default_factory=lambda: ["grid", "speaker", "screenshare"])
|
||||||
|
display_speaker_camera: str = "cam-01" # cam-NN or "active"
|
||||||
|
display_speaker_participant: str = "speaker"
|
||||||
|
display_grid_cols: int = 5
|
||||||
|
display_grid_rows: int = 4
|
||||||
|
display_grid_width: int = 1920
|
||||||
|
display_grid_height: int = 1080
|
||||||
|
display_grid_fps: int = 30
|
||||||
|
livekit_public_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _parse_cameras(spec: str) -> list[str]:
|
def _parse_cameras(spec: str) -> list[str]:
|
||||||
@@ -66,6 +86,14 @@ def _parse_cameras(spec: str) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_list(spec: str) -> list[str]:
|
||||||
|
"""Comma-separated names, preserving case (HDMI-A-7, cam-01)."""
|
||||||
|
spec = (spec or "").strip()
|
||||||
|
if not spec:
|
||||||
|
return []
|
||||||
|
return [p.strip() for p in spec.split(",") if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
def load_config(env_path: Optional[Path] = None) -> Config:
|
def load_config(env_path: Optional[Path] = None) -> Config:
|
||||||
_load_dotenv(Path(env_path or Path(__file__).parent / ".env"))
|
_load_dotenv(Path(env_path or Path(__file__).parent / ".env"))
|
||||||
|
|
||||||
@@ -75,10 +103,14 @@ def load_config(env_path: Optional[Path] = None) -> Config:
|
|||||||
api_secret=os.environ.get("LIVEKIT_API_SECRET", ""),
|
api_secret=os.environ.get("LIVEKIT_API_SECRET", ""),
|
||||||
room=os.environ.get("LIVEKIT_ROOM", "cameras"),
|
room=os.environ.get("LIVEKIT_ROOM", "cameras"),
|
||||||
participant_prefix=os.environ.get("PARTICIPANT_PREFIX", "cam"),
|
participant_prefix=os.environ.get("PARTICIPANT_PREFIX", "cam"),
|
||||||
width=int(os.environ.get("VIDEO_WIDTH", "1280")),
|
width=int(os.environ.get("VIDEO_WIDTH", "640")),
|
||||||
height=int(os.environ.get("VIDEO_HEIGHT", "720")),
|
height=int(os.environ.get("VIDEO_HEIGHT", "360")),
|
||||||
fps=int(os.environ.get("VIDEO_FPS", "30")),
|
fps=int(os.environ.get("VIDEO_FPS", "30")),
|
||||||
min_fps=int(os.environ.get("VIDEO_MIN_FPS", "5")),
|
min_fps=int(os.environ.get("VIDEO_MIN_FPS", "5")),
|
||||||
|
video_bitrate=int(os.environ.get("VIDEO_BITRATE", "800000")),
|
||||||
|
video_codec=os.environ.get("VIDEO_CODEC", "h264").lower(),
|
||||||
|
video_encoder=os.environ.get("VIDEO_ENCODER", "vaapi").lower(),
|
||||||
|
vaapi_device=os.environ.get("LIBVA_DRM_DEVICE", "/dev/dri/renderD129"),
|
||||||
audio_rate=int(os.environ.get("AUDIO_SAMPLE_RATE", "16000")),
|
audio_rate=int(os.environ.get("AUDIO_SAMPLE_RATE", "16000")),
|
||||||
audio_channels=int(os.environ.get("AUDIO_CHANNELS", "1")),
|
audio_channels=int(os.environ.get("AUDIO_CHANNELS", "1")),
|
||||||
enhance_mode=os.environ.get("ENHANCE_MODE", "auto").lower(),
|
enhance_mode=os.environ.get("ENHANCE_MODE", "auto").lower(),
|
||||||
@@ -93,6 +125,23 @@ def load_config(env_path: Optional[Path] = None) -> Config:
|
|||||||
publish_timeout_s=float(os.environ.get("PUBLISH_TIMEOUT_S", "60")),
|
publish_timeout_s=float(os.environ.get("PUBLISH_TIMEOUT_S", "60")),
|
||||||
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||||
cameras=_parse_cameras(os.environ.get("CAMERAS", "all")),
|
cameras=_parse_cameras(os.environ.get("CAMERAS", "all")),
|
||||||
|
display_count=int(os.environ.get("DISPLAY_COUNT", "3")),
|
||||||
|
display_identities=_parse_list(
|
||||||
|
os.environ.get("DISPLAY_IDENTITIES", "cam-01,cam-02,cam-03")),
|
||||||
|
display_connectors=_parse_list(os.environ.get("DISPLAY_CONNECTORS", "")),
|
||||||
|
display_identity=os.environ.get("DISPLAY_IDENTITY", "display-wall"),
|
||||||
|
display_roles=_parse_list(
|
||||||
|
os.environ.get("DISPLAY_ROLES", "grid,speaker,screenshare"))
|
||||||
|
or ["grid", "speaker", "screenshare"],
|
||||||
|
display_speaker_camera=os.environ.get("DISPLAY_SPEAKER_CAMERA", "cam-01"),
|
||||||
|
display_speaker_participant=os.environ.get(
|
||||||
|
"DISPLAY_SPEAKER_PARTICIPANT", "speaker"),
|
||||||
|
display_grid_cols=int(os.environ.get("DISPLAY_GRID_COLS", "5")),
|
||||||
|
display_grid_rows=int(os.environ.get("DISPLAY_GRID_ROWS", "4")),
|
||||||
|
display_grid_width=int(os.environ.get("DISPLAY_GRID_WIDTH", "1920")),
|
||||||
|
display_grid_height=int(os.environ.get("DISPLAY_GRID_HEIGHT", "1080")),
|
||||||
|
display_grid_fps=int(os.environ.get("DISPLAY_GRID_FPS", "30")),
|
||||||
|
livekit_public_url=os.environ.get("LIVEKIT_PUBLIC_URL", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cfg.enhance_mode not in ("auto", "force", "off"):
|
if cfg.enhance_mode not in ("auto", "force", "off"):
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Drive 3 DRM displays with LiveKit camera output (GStreamer kmssink)
|
||||||
|
Documentation=file:///home/fdenkena/livekit-cameras/README.md
|
||||||
|
After=network-online.target livekit-server.service livekit-cameras.service
|
||||||
|
Wants=network-online.target livekit-cameras.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/fdenkena/livekit-cameras
|
||||||
|
EnvironmentFile=-/home/fdenkena/livekit-cameras/.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
# Exclusive KMS access — no X/Wayland on these connectors.
|
||||||
|
ExecStartPre=/home/fdenkena/livekit-cameras/deploy/wait-for-livekit.sh
|
||||||
|
ExecStart=/home/fdenkena/livekit-cameras/.venv/bin/python /home/fdenkena/livekit-cameras/run_displays.py
|
||||||
|
ExecStop=/bin/kill -TERM $MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStartSec=60
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=control-group
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
"""20-camera mosaic + framed tiles with name placeholders for the KMS wall."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ROLES = ("grid", "speaker", "screenshare")
|
||||||
|
|
||||||
|
# BGRA
|
||||||
|
CANVAS_BG = (10, 10, 10, 255)
|
||||||
|
CELL_BG = (18, 18, 18, 255)
|
||||||
|
EMPTY_FILL = (36, 36, 36, 255)
|
||||||
|
FRAME_COLOR = (196, 196, 196, 255)
|
||||||
|
FRAME_LIVE = (168, 168, 168, 255)
|
||||||
|
LABEL_BG = (12, 12, 12, 255)
|
||||||
|
LABEL_FG = (245, 245, 245, 255)
|
||||||
|
NAME_FG = (220, 220, 220, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def camera_ids(n: int = 20, prefix: str = "cam") -> list[str]:
|
||||||
|
return [f"{prefix}-{i:02d}" for i in range(1, n + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_camera_identity(identity: str, prefix: str = "cam") -> bool:
|
||||||
|
ident = (identity or "").strip()
|
||||||
|
p = prefix.rstrip("-")
|
||||||
|
return ident.startswith(p + "-") or ident.startswith(p)
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(identity: str) -> str:
|
||||||
|
"""Human label for a camera identity (cam-07 -> CAM-07)."""
|
||||||
|
return (identity or "").strip().upper() or "CAMERA"
|
||||||
|
|
||||||
|
|
||||||
|
def letterbox_bgra(src: np.ndarray, tw: int, th: int) -> np.ndarray:
|
||||||
|
"""Scale BGRA `src` into tw x th, preserving aspect (black bars)."""
|
||||||
|
if tw < 1 or th < 1:
|
||||||
|
raise ValueError("tile size must be positive")
|
||||||
|
if src.ndim != 3 or src.shape[2] not in (3, 4):
|
||||||
|
raise ValueError(f"expected HxWx3/4, got {src.shape}")
|
||||||
|
h, w = src.shape[:2]
|
||||||
|
if src.shape[2] == 3:
|
||||||
|
bgra = cv2.cvtColor(src, cv2.COLOR_BGR2BGRA)
|
||||||
|
else:
|
||||||
|
bgra = src
|
||||||
|
scale = min(tw / w, th / h)
|
||||||
|
nw = max(1, int(round(w * scale)))
|
||||||
|
nh = max(1, int(round(h * scale)))
|
||||||
|
resized = cv2.resize(bgra, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||||
|
out = np.zeros((th, tw, 4), dtype=np.uint8)
|
||||||
|
y = (th - nh) // 2
|
||||||
|
x = (tw - nw) // 2
|
||||||
|
out[y : y + nh, x : x + nw] = resized
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bgra_from_livekit(data: memoryview | bytes, width: int, height: int) -> np.ndarray:
|
||||||
|
arr = np.frombuffer(data, dtype=np.uint8)
|
||||||
|
return arr.reshape((height, width, 4)).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder(
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
lines: list[str],
|
||||||
|
*,
|
||||||
|
color: tuple[int, int, int, int] = (24, 24, 24, 255),
|
||||||
|
) -> np.ndarray:
|
||||||
|
img = np.zeros((height, width, 4), dtype=np.uint8)
|
||||||
|
img[:] = color
|
||||||
|
if not lines:
|
||||||
|
return img
|
||||||
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||||
|
scale = max(0.5, min(width / 960.0, height / 540.0) * 1.2)
|
||||||
|
thick = 2 if scale >= 0.8 else 1
|
||||||
|
sizes = [cv2.getTextSize(t, font, scale, thick)[0] for t in lines]
|
||||||
|
total_h = sum(s[1] for s in sizes) + 12 * (len(lines) - 1)
|
||||||
|
y = (height - total_h) // 2 + sizes[0][1]
|
||||||
|
for text, (tw, th) in zip(lines, sizes):
|
||||||
|
x = max(8, (width - tw) // 2)
|
||||||
|
cv2.putText(img, text, (x, y), font, scale, (240, 240, 240, 255), thick, cv2.LINE_AA)
|
||||||
|
y += th + 12
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_text(text: str, max_w: int, max_h: int) -> tuple[float, int, tuple[int, int]]:
|
||||||
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||||
|
scale = max(0.35, min(max_w / 220.0, max_h / 36.0))
|
||||||
|
thick = 2 if scale >= 0.7 else 1
|
||||||
|
for _ in range(8):
|
||||||
|
(tw, th), _ = cv2.getTextSize(text, font, scale, thick)
|
||||||
|
if tw <= max_w and th <= max_h:
|
||||||
|
return scale, thick, (tw, th)
|
||||||
|
scale *= 0.85
|
||||||
|
if scale < 0.3:
|
||||||
|
break
|
||||||
|
(tw, th), _ = cv2.getTextSize(text, font, scale, thick)
|
||||||
|
return scale, thick, (tw, th)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_centered(img: np.ndarray, text: str, box: tuple[int, int, int, int],
|
||||||
|
color=NAME_FG) -> None:
|
||||||
|
x0, y0, x1, y1 = box
|
||||||
|
w, h = max(1, x1 - x0), max(1, y1 - y0)
|
||||||
|
scale, thick, (tw, th) = _fit_text(text, w - 8, h - 6)
|
||||||
|
x = x0 + max(2, (w - tw) // 2)
|
||||||
|
y = y0 + max(th + 2, (h + th) // 2)
|
||||||
|
cv2.putText(img, text, (x, y), cv2.FONT_HERSHEY_SIMPLEX, scale, color, thick, cv2.LINE_AA)
|
||||||
|
|
||||||
|
|
||||||
|
class CameraGrid:
|
||||||
|
"""5x4 (default) mosaic of framed camera tiles with name placeholders."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width: int = 1920,
|
||||||
|
height: int = 1080,
|
||||||
|
cols: int = 5,
|
||||||
|
rows: int = 4,
|
||||||
|
identities: Optional[Iterable[str]] = None,
|
||||||
|
gap: int = 8,
|
||||||
|
border: int = 3,
|
||||||
|
label_h: int = 28,
|
||||||
|
) -> None:
|
||||||
|
if cols < 1 or rows < 1:
|
||||||
|
raise ValueError("cols/rows must be >= 1")
|
||||||
|
self.width = int(width)
|
||||||
|
self.height = int(height)
|
||||||
|
self.cols = int(cols)
|
||||||
|
self.rows = int(rows)
|
||||||
|
self.gap = max(0, int(gap))
|
||||||
|
self.border = max(1, int(border))
|
||||||
|
self.label_h = max(16, int(label_h))
|
||||||
|
self.identities = list(identities) if identities is not None else camera_ids(cols * rows)
|
||||||
|
self.tile_w = max(32, (self.width - self.gap * (self.cols + 1)) // self.cols)
|
||||||
|
self.tile_h = max(32, (self.height - self.gap * (self.rows + 1)) // self.rows)
|
||||||
|
self.inner_w = max(1, self.tile_w - 2 * self.border)
|
||||||
|
self.inner_h = max(1, self.tile_h - 2 * self.border - self.label_h)
|
||||||
|
self._live: set[str] = set()
|
||||||
|
self._tiles: dict[str, np.ndarray] = {}
|
||||||
|
for ident in self.identities:
|
||||||
|
self._tiles[ident] = self._compose_cell(ident, None)
|
||||||
|
self._canvas = np.zeros((self.height, self.width, 4), dtype=np.uint8)
|
||||||
|
|
||||||
|
def _compose_cell(self, identity: str, inner: Optional[np.ndarray]) -> np.ndarray:
|
||||||
|
tw, th = self.tile_w, self.tile_h
|
||||||
|
b = self.border
|
||||||
|
cell = np.empty((th, tw, 4), dtype=np.uint8)
|
||||||
|
cell[:] = CELL_BG
|
||||||
|
frame = FRAME_LIVE if inner is not None else FRAME_COLOR
|
||||||
|
cv2.rectangle(cell, (0, 0), (tw - 1, th - 1), frame, b)
|
||||||
|
# video / placeholder region
|
||||||
|
iy0, ix0 = b, b
|
||||||
|
iy1, ix1 = b + self.inner_h, b + self.inner_w
|
||||||
|
if inner is not None:
|
||||||
|
fitted = letterbox_bgra(inner, self.inner_w, self.inner_h)
|
||||||
|
cell[iy0:iy1, ix0:ix1] = fitted
|
||||||
|
else:
|
||||||
|
cell[iy0:iy1, ix0:ix1] = EMPTY_FILL
|
||||||
|
_draw_centered(cell, display_name(identity), (ix0, iy0, ix1, iy1))
|
||||||
|
# name bar under the feed, inside the frame
|
||||||
|
ly0 = th - b - self.label_h
|
||||||
|
cell[ly0 : th - b, b : tw - b] = LABEL_BG
|
||||||
|
_draw_centered(
|
||||||
|
cell, display_name(identity),
|
||||||
|
(b + 2, ly0, tw - b - 2, th - b),
|
||||||
|
color=LABEL_FG,
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
|
||||||
|
def set_frame(self, identity: str, bgra: np.ndarray) -> None:
|
||||||
|
if identity not in self._tiles:
|
||||||
|
return
|
||||||
|
self._live.add(identity)
|
||||||
|
self._tiles[identity] = self._compose_cell(identity, bgra)
|
||||||
|
|
||||||
|
def clear(self, identity: str) -> None:
|
||||||
|
if identity not in self._tiles:
|
||||||
|
return
|
||||||
|
self._live.discard(identity)
|
||||||
|
self._tiles[identity] = self._compose_cell(identity, None)
|
||||||
|
|
||||||
|
def render(self) -> bytes:
|
||||||
|
canvas = self._canvas
|
||||||
|
canvas[:] = CANVAS_BG
|
||||||
|
g = self.gap
|
||||||
|
for i, ident in enumerate(self.identities):
|
||||||
|
r, c = divmod(i, self.cols)
|
||||||
|
if r >= self.rows:
|
||||||
|
break
|
||||||
|
y = g + r * (self.tile_h + g)
|
||||||
|
x = g + c * (self.tile_w + g)
|
||||||
|
tile = self._tiles[ident]
|
||||||
|
canvas[y : y + self.tile_h, x : x + self.tile_w] = tile
|
||||||
|
return canvas.tobytes()
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
"""Discover DRM/KMS connectors for headless kmssink output."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DRM_CLASS = Path("/sys/class/drm")
|
||||||
|
|
||||||
|
# xf86drmMode.h DRM_MODE_CONNECTOR_*
|
||||||
|
_TYPE_SYSFS = {
|
||||||
|
10: "DP",
|
||||||
|
11: "HDMI-A",
|
||||||
|
12: "HDMI-B",
|
||||||
|
14: "eDP",
|
||||||
|
15: "Virtual",
|
||||||
|
}
|
||||||
|
_STATUS = {1: "connected", 2: "disconnected", 3: "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrmOutput:
|
||||||
|
name: str # e.g. HDMI-A-7
|
||||||
|
sysfs: str # card0-HDMI-A-7
|
||||||
|
node: str # /dev/dri/card0
|
||||||
|
driver: str # xe / i915
|
||||||
|
connector_id: int
|
||||||
|
status: str
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
crtc_id: int = 0
|
||||||
|
plane_id: int = 0
|
||||||
|
bus_id: str = "" # pci:0000:03:00.0 style for kmssink
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self.status == "connected"
|
||||||
|
|
||||||
|
|
||||||
|
def _pci_bus_id(node: str) -> str:
|
||||||
|
link = Path(f"/sys/class/drm/{Path(node).name}/device")
|
||||||
|
try:
|
||||||
|
uevent = (link / "uevent").read_text()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
for line in uevent.splitlines():
|
||||||
|
if line.startswith("PCI_SLOT_NAME="):
|
||||||
|
slot = line.split("=", 1)[1].strip()
|
||||||
|
return f"pci:{slot}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sysfs_connectors() -> dict[str, dict]:
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
if not DRM_CLASS.is_dir():
|
||||||
|
return out
|
||||||
|
for p in sorted(DRM_CLASS.iterdir()):
|
||||||
|
name = p.name
|
||||||
|
if not name.startswith("card") or "-" not in name:
|
||||||
|
continue
|
||||||
|
card, conn = name.split("-", 1)
|
||||||
|
status = (p / "status").read_text().strip() if (p / "status").is_file() else "unknown"
|
||||||
|
modes: list[str] = []
|
||||||
|
if (p / "modes").is_file():
|
||||||
|
modes = [m.strip() for m in (p / "modes").read_text().splitlines() if m.strip()]
|
||||||
|
w = h = 0
|
||||||
|
if modes and "x" in modes[0]:
|
||||||
|
try:
|
||||||
|
wh, *_ = modes[0].split("@")
|
||||||
|
w, h = (int(x) for x in wh.split("x")[:2])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
out[name] = {
|
||||||
|
"card": card,
|
||||||
|
"conn": conn,
|
||||||
|
"status": status,
|
||||||
|
"modes": modes,
|
||||||
|
"width": w,
|
||||||
|
"height": h,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _drm_info() -> dict:
|
||||||
|
try:
|
||||||
|
raw = subprocess.check_output(["drm_info", "-j"], text=True, timeout=10)
|
||||||
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
return {}
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _plane_type(plane: dict) -> int:
|
||||||
|
props = plane.get("properties") or {}
|
||||||
|
t = props.get("type") or {}
|
||||||
|
return int(t.get("value") or t.get("raw_value") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_planes(info: dict) -> list[dict]:
|
||||||
|
planes = info.get("planes") or []
|
||||||
|
return [p for p in planes if _plane_type(p) == 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_sysfs(card: str, typ_name: str, drm_conns: list[dict], sysfs: dict) -> list[tuple[dict, str]]:
|
||||||
|
names = []
|
||||||
|
for key, meta in sysfs.items():
|
||||||
|
if meta["card"] == card and meta["conn"].startswith(typ_name + "-"):
|
||||||
|
names.append(key)
|
||||||
|
names.sort(key=lambda k: int(sysfs[k]["conn"].rsplit("-", 1)[-1]))
|
||||||
|
drm_conns = sorted(drm_conns, key=lambda c: int(c["id"]))
|
||||||
|
return list(zip(drm_conns, names))
|
||||||
|
|
||||||
|
|
||||||
|
def list_outputs() -> list[DrmOutput]:
|
||||||
|
sysfs = _sysfs_connectors()
|
||||||
|
info = _drm_info()
|
||||||
|
outputs: list[DrmOutput] = []
|
||||||
|
for node, ninfo in info.items():
|
||||||
|
driver = ((ninfo.get("driver") or {}).get("name")) or ""
|
||||||
|
card = Path(node).name # card0
|
||||||
|
bus_id = _pci_bus_id(node)
|
||||||
|
crtcs = ninfo.get("crtcs") or []
|
||||||
|
primaries = _primary_planes(ninfo)
|
||||||
|
by_type: dict[int, list[dict]] = {}
|
||||||
|
for c in ninfo.get("connectors") or []:
|
||||||
|
by_type.setdefault(int(c.get("type") or 0), []).append(c)
|
||||||
|
assigned_crtc: set[int] = set()
|
||||||
|
for typ, conns in by_type.items():
|
||||||
|
typ_name = _TYPE_SYSFS.get(typ)
|
||||||
|
if not typ_name:
|
||||||
|
continue
|
||||||
|
for c, sys_key in _pair_sysfs(card, typ_name, conns, sysfs):
|
||||||
|
meta = sysfs[sys_key]
|
||||||
|
status = _STATUS.get(int(c.get("status") or 0), meta["status"]) or "unknown"
|
||||||
|
modes = c.get("modes") or []
|
||||||
|
w = h = 0
|
||||||
|
if modes:
|
||||||
|
m0 = modes[0]
|
||||||
|
w = int(m0.get("hdisplay") or 0)
|
||||||
|
h = int(m0.get("vdisplay") or 0)
|
||||||
|
if not w:
|
||||||
|
w, h = meta["width"], meta["height"]
|
||||||
|
crtc_id = 0
|
||||||
|
plane_id = 0
|
||||||
|
props = c.get("properties") or {}
|
||||||
|
crtc_prop = props.get("CRTC_ID") or {}
|
||||||
|
already = int(crtc_prop.get("value") or 0)
|
||||||
|
if already:
|
||||||
|
crtc_id = already
|
||||||
|
assigned_crtc.add(already)
|
||||||
|
outputs.append(
|
||||||
|
DrmOutput(
|
||||||
|
name=meta["conn"],
|
||||||
|
sysfs=sys_key,
|
||||||
|
node=node,
|
||||||
|
driver=driver,
|
||||||
|
connector_id=int(c["id"]),
|
||||||
|
status=status,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
crtc_id=crtc_id,
|
||||||
|
plane_id=plane_id,
|
||||||
|
bus_id=bus_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# assign unused CRTCs + primary planes for disconnected heads
|
||||||
|
crtc_ids = [int(cr["id"]) for cr in crtcs]
|
||||||
|
unused = [i for i in crtc_ids if i not in assigned_crtc]
|
||||||
|
card_outs = [o for o in outputs if Path(o.node).name == card]
|
||||||
|
|
||||||
|
def crtc_rank(o: DrmOutput) -> tuple:
|
||||||
|
return (0 if o.connected else 1, 0 if "HDMI" in o.name else 1, o.name)
|
||||||
|
|
||||||
|
for out in sorted(card_outs, key=crtc_rank):
|
||||||
|
if not out.crtc_id and unused:
|
||||||
|
out.crtc_id = unused.pop(0)
|
||||||
|
if out.crtc_id:
|
||||||
|
try:
|
||||||
|
idx = crtc_ids.index(out.crtc_id)
|
||||||
|
except ValueError:
|
||||||
|
idx = 0
|
||||||
|
mask = 1 << idx
|
||||||
|
for p in primaries:
|
||||||
|
if int(p.get("possible_crtcs") or 0) & mask:
|
||||||
|
out.plane_id = int(p["id"])
|
||||||
|
break
|
||||||
|
outputs.sort(key=lambda o: (o.node, o.name))
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def select_outputs(
|
||||||
|
n: int = 3,
|
||||||
|
names: Optional[list[str]] = None,
|
||||||
|
connected_only: bool = False,
|
||||||
|
) -> list[DrmOutput]:
|
||||||
|
all_outs = list_outputs()
|
||||||
|
if names:
|
||||||
|
by_name = {o.name: o for o in all_outs}
|
||||||
|
picked = []
|
||||||
|
for name in names:
|
||||||
|
if name not in by_name:
|
||||||
|
raise KeyError(f"DRM connector {name!r} not found. Have: {sorted(by_name)}")
|
||||||
|
picked.append(by_name[name])
|
||||||
|
return picked
|
||||||
|
connected = [o for o in all_outs if o.connected]
|
||||||
|
rest = [o for o in all_outs if not o.connected]
|
||||||
|
# Prefer the discrete GPU (xe / Arc) HDMI heads for a 3-display wall.
|
||||||
|
def rank(o: DrmOutput) -> tuple:
|
||||||
|
return (
|
||||||
|
0 if o.driver == "xe" else 1,
|
||||||
|
0 if o.name.startswith("HDMI-A") else 1,
|
||||||
|
o.name,
|
||||||
|
)
|
||||||
|
connected.sort(key=rank)
|
||||||
|
rest.sort(key=rank)
|
||||||
|
ordered = connected + ([] if connected_only else rest)
|
||||||
|
return ordered[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def format_outputs(outs: list[DrmOutput]) -> str:
|
||||||
|
lines = []
|
||||||
|
for o in outs:
|
||||||
|
mode = f"{o.width}x{o.height}" if o.width else "?"
|
||||||
|
lines.append(
|
||||||
|
f"{o.name:12} {o.status:12} id={o.connector_id:<4} "
|
||||||
|
f"{o.driver:4} {o.node} crtc={o.crtc_id} plane={o.plane_id} {mode}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
"""GStreamer kmssink pipelines for one DRM connector."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from drm_outputs import DrmOutput
|
||||||
|
|
||||||
|
log = logging.getLogger("cameras.display")
|
||||||
|
|
||||||
|
GST_LAUNCH = shutil.which("gst-launch-1.0") or "gst-launch-1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _kms_props(out: DrmOutput) -> list[str]:
|
||||||
|
props = [
|
||||||
|
"kmssink",
|
||||||
|
f"connector-id={out.connector_id}",
|
||||||
|
"force-modesetting=true",
|
||||||
|
"can-scale=true",
|
||||||
|
"sync=false",
|
||||||
|
"restore-crtc=true",
|
||||||
|
]
|
||||||
|
if out.driver:
|
||||||
|
props.append(f"driver-name={out.driver}")
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_cmd(out: DrmOutput, pattern: str = "smpte", width: int = 1280, height: int = 720) -> list[str]:
|
||||||
|
return [
|
||||||
|
GST_LAUNCH, "-e",
|
||||||
|
"videotestsrc", "is-live=true", f"pattern={pattern}",
|
||||||
|
"!", f"video/x-raw,width={width},height={height},framerate=30/1",
|
||||||
|
"!", "videoconvert",
|
||||||
|
"!", "videoscale",
|
||||||
|
"!", *_kms_props(out),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def appsrc_cmd(out: DrmOutput, width: int, height: int, fps: int = 30) -> list[str]:
|
||||||
|
"""Raw BGRA frames on stdin -> kmssink."""
|
||||||
|
block = width * height * 4
|
||||||
|
return [
|
||||||
|
GST_LAUNCH, "-e",
|
||||||
|
"fdsrc", "fd=0", f"blocksize={block}",
|
||||||
|
"!", "videoparse",
|
||||||
|
f"width={width}", f"height={height}", "format=bgra", f"framerate={fps}/1",
|
||||||
|
"!", "queue", "max-size-buffers=2", "leaky=downstream",
|
||||||
|
"!", "videoconvert",
|
||||||
|
"!", "videoscale", "method=0",
|
||||||
|
"!", *_kms_props(out),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class KmsPipeline:
|
||||||
|
"""One gst-launch kmssink process, fed raw BGRA on stdin (or self-running testsrc)."""
|
||||||
|
|
||||||
|
def __init__(self, out: DrmOutput, identity: str):
|
||||||
|
self.out = out
|
||||||
|
self.identity = identity
|
||||||
|
self.proc: Optional[subprocess.Popen] = None
|
||||||
|
self.width = 0
|
||||||
|
self.height = 0
|
||||||
|
self.mode = "idle"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.proc is not None and self.proc.poll() is None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.proc is None:
|
||||||
|
return
|
||||||
|
if self.proc.stdin:
|
||||||
|
try:
|
||||||
|
self.proc.stdin.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if self.proc.poll() is None:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc.wait(timeout=2)
|
||||||
|
self.proc = None
|
||||||
|
self.mode = "idle"
|
||||||
|
|
||||||
|
def start_test_pattern(self, pattern: str = "smpte") -> None:
|
||||||
|
self.stop()
|
||||||
|
if not self.out.connected:
|
||||||
|
log.warning("%s: %s not connected, skip test pattern", self.identity, self.out.name)
|
||||||
|
return
|
||||||
|
cmd = test_pattern_cmd(self.out, pattern=pattern)
|
||||||
|
log.info("%s: gst test pattern %s on %s id=%s", self.identity, pattern, self.out.name, self.out.connector_id)
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=_gst_env(),
|
||||||
|
)
|
||||||
|
self.mode = "pattern"
|
||||||
|
|
||||||
|
def start_appsrc(self, width: int, height: int, fps: int = 30) -> None:
|
||||||
|
if self.alive and self.mode == "appsrc" and self.width == width and self.height == height:
|
||||||
|
return
|
||||||
|
self.stop()
|
||||||
|
if not self.out.connected:
|
||||||
|
log.warning("%s: %s not connected, cannot start kmssink", self.identity, self.out.name)
|
||||||
|
return
|
||||||
|
cmd = appsrc_cmd(self.out, width, height, fps)
|
||||||
|
log.info(
|
||||||
|
"%s: gst appsrc %dx%d -> %s (connector %s %s)",
|
||||||
|
self.identity, width, height, self.out.name, self.out.connector_id, self.out.driver,
|
||||||
|
)
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=_gst_env(),
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.mode = "appsrc"
|
||||||
|
|
||||||
|
def push(self, frame: bytes) -> bool:
|
||||||
|
if not self.alive or self.proc is None or self.proc.stdin is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.proc.stdin.write(frame)
|
||||||
|
return True
|
||||||
|
except BrokenPipeError:
|
||||||
|
log.warning("%s: gst stdin closed (rc=%s)", self.identity, self.proc.poll())
|
||||||
|
self.proc = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def gst_errors(self) -> str:
|
||||||
|
if self.proc is None or self.proc.stderr is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
os.set_blocking(self.proc.stderr.fileno(), False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
chunk = self.proc.stderr.read() or b""
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
return chunk.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _gst_env() -> dict:
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Never try X11/Wayland; kmssink is the point.
|
||||||
|
env.pop("DISPLAY", None)
|
||||||
|
env.pop("WAYLAND_DISPLAY", None)
|
||||||
|
env.setdefault("GST_GL_PLATFORM", "egl")
|
||||||
|
return env
|
||||||
@@ -37,6 +37,10 @@ def worker_args_for(cfg, cam) -> list[str]:
|
|||||||
"--width", str(cfg.width),
|
"--width", str(cfg.width),
|
||||||
"--height", str(cfg.height),
|
"--height", str(cfg.height),
|
||||||
"--fps", str(cfg.fps),
|
"--fps", str(cfg.fps),
|
||||||
|
"--bitrate", str(cfg.video_bitrate),
|
||||||
|
"--video-codec", cfg.video_codec,
|
||||||
|
"--video-encoder", cfg.video_encoder,
|
||||||
|
"--vaapi-device", cfg.vaapi_device,
|
||||||
"--audio-rate", str(cfg.audio_rate),
|
"--audio-rate", str(cfg.audio_rate),
|
||||||
"--enhance-mode", cfg.enhance_mode,
|
"--enhance-mode", cfg.enhance_mode,
|
||||||
"--enhance-model", cfg.enhance_model,
|
"--enhance-model", cfg.enhance_model,
|
||||||
@@ -59,6 +63,8 @@ class Supervisor:
|
|||||||
def spawn(self, cam) -> None:
|
def spawn(self, cam) -> None:
|
||||||
args = worker_args_for(self.cfg, cam)
|
args = worker_args_for(self.cfg, cam)
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
|
env.setdefault("LIBVA_DRIVER_NAME", "iHD")
|
||||||
|
env.setdefault("LIBVA_DRM_DEVICE", self.cfg.vaapi_device)
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
[sys.executable, str(Path(__file__).parent / "run_worker.py"),
|
[sys.executable, str(Path(__file__).parent / "run_worker.py"),
|
||||||
*args],
|
*args],
|
||||||
|
|||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Headless 3-display LiveKit wall via GStreamer kmssink.
|
||||||
|
|
||||||
|
1. grid — 5x4 mosaic of cam-01..cam-20
|
||||||
|
2. speaker — designated speaker camera (DISPLAY_SPEAKER_CAMERA)
|
||||||
|
3. screenshare — STUB until the production LiveKit server is online
|
||||||
|
|
||||||
|
.venv/bin/python run_displays.py --list
|
||||||
|
.venv/bin/python run_displays.py --test-pattern
|
||||||
|
.venv/bin/python run_displays.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from display_grid import (
|
||||||
|
CameraGrid,
|
||||||
|
ROLES,
|
||||||
|
bgra_from_livekit,
|
||||||
|
camera_ids,
|
||||||
|
is_camera_identity,
|
||||||
|
placeholder,
|
||||||
|
)
|
||||||
|
from drm_outputs import DrmOutput, format_outputs, list_outputs, select_outputs
|
||||||
|
from gst_sink import KmsPipeline
|
||||||
|
from tokens import make_viewer_token
|
||||||
|
|
||||||
|
log = logging.getLogger("cameras.display")
|
||||||
|
|
||||||
|
_PATTERNS = ("smpte", "ball", "snow")
|
||||||
|
_PLACE_W, _PLACE_H = 1280, 720
|
||||||
|
|
||||||
|
|
||||||
|
def _public_url(cfg) -> str:
|
||||||
|
if cfg.livekit_public_url:
|
||||||
|
return cfg.livekit_public_url.rstrip("/")
|
||||||
|
parsed = urlparse(cfg.url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
if host in ("127.0.0.1", "localhost"):
|
||||||
|
host = "10.200.0.20"
|
||||||
|
scheme = parsed.scheme or "ws"
|
||||||
|
port = parsed.port
|
||||||
|
if port:
|
||||||
|
return f"{scheme}://{host}:{port}"
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_roles(roles: list[str], connectors: list[str]) -> list[tuple[str, DrmOutput]]:
|
||||||
|
roles = [r.strip().lower() for r in roles if r.strip()]
|
||||||
|
for r in roles:
|
||||||
|
if r not in ROLES:
|
||||||
|
raise KeyError(f"unknown display role {r!r}; expected {ROLES}")
|
||||||
|
names = connectors if connectors else None
|
||||||
|
outs = select_outputs(n=len(roles), names=names)
|
||||||
|
if len(outs) < len(roles):
|
||||||
|
log.warning("wanted %d displays, found %d DRM connectors", len(roles), len(outs))
|
||||||
|
slots = list(zip(roles, outs))
|
||||||
|
for role, out in slots:
|
||||||
|
log.info("role %s -> %s (%s connector-id=%s %s)",
|
||||||
|
role, out.name, out.status, out.connector_id, out.driver)
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
class Wall:
|
||||||
|
def __init__(self, cfg, slots: list[tuple[str, DrmOutput]]):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.slots = {role: KmsPipeline(out, role) for role, out in slots}
|
||||||
|
prefix = cfg.participant_prefix or "cam"
|
||||||
|
n = cfg.display_grid_cols * cfg.display_grid_rows
|
||||||
|
self.grid = CameraGrid(
|
||||||
|
width=cfg.display_grid_width,
|
||||||
|
height=cfg.display_grid_height,
|
||||||
|
cols=cfg.display_grid_cols,
|
||||||
|
rows=cfg.display_grid_rows,
|
||||||
|
identities=camera_ids(n, prefix),
|
||||||
|
)
|
||||||
|
self.speaker_camera = (cfg.display_speaker_camera or "cam-01").strip()
|
||||||
|
self.active_speaker: Optional[str] = None
|
||||||
|
self._speaker_frame: Optional[np.ndarray] = None
|
||||||
|
self._tasks: dict[str, asyncio.Task] = {}
|
||||||
|
self._stop = asyncio.Event()
|
||||||
|
# Screenshare is a stub until production LiveKit is online.
|
||||||
|
self._share_placeholder = placeholder(_PLACE_W, _PLACE_H, [
|
||||||
|
"Screen share",
|
||||||
|
"stub — waiting for production LiveKit",
|
||||||
|
_public_url(cfg),
|
||||||
|
])
|
||||||
|
cam = self.speaker_camera
|
||||||
|
self._speaker_placeholder = placeholder(_PLACE_W, _PLACE_H, [
|
||||||
|
"Speaker camera",
|
||||||
|
cam if cam.lower() != "active" else "(following active speaker)",
|
||||||
|
])
|
||||||
|
|
||||||
|
def speaker_identity(self) -> Optional[str]:
|
||||||
|
if self.speaker_camera.lower() == "active":
|
||||||
|
return self.active_speaker
|
||||||
|
return self.speaker_camera
|
||||||
|
|
||||||
|
def _push_sink(self, role: str, data: bytes, width: int, height: int, fps: int) -> None:
|
||||||
|
sink = self.slots.get(role)
|
||||||
|
if sink is None or not sink.out.connected:
|
||||||
|
return
|
||||||
|
if not sink.alive or sink.width != width or sink.height != height:
|
||||||
|
sink.start_appsrc(width, height, fps)
|
||||||
|
if not sink.push(data):
|
||||||
|
sink.start_appsrc(width, height, fps)
|
||||||
|
sink.push(data)
|
||||||
|
err = sink.gst_errors()
|
||||||
|
if err:
|
||||||
|
for line in err.strip().splitlines()[-6:]:
|
||||||
|
log.warning("%s gst: %s", role, line)
|
||||||
|
|
||||||
|
async def _output_loops(self) -> None:
|
||||||
|
grid_fps = max(1, int(self.cfg.display_grid_fps))
|
||||||
|
grid_period = 1.0 / grid_fps
|
||||||
|
spk_period = 1.0 / max(1, int(self.cfg.fps))
|
||||||
|
next_grid = time.monotonic()
|
||||||
|
next_spk = time.monotonic()
|
||||||
|
while not self._stop.is_set():
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= next_grid:
|
||||||
|
self._push_sink(
|
||||||
|
"grid", self.grid.render(),
|
||||||
|
self.grid.width, self.grid.height, grid_fps,
|
||||||
|
)
|
||||||
|
next_grid = now + grid_period
|
||||||
|
if now >= next_spk:
|
||||||
|
spk = self._speaker_frame
|
||||||
|
if spk is None:
|
||||||
|
img = self._speaker_placeholder
|
||||||
|
else:
|
||||||
|
img = spk
|
||||||
|
self._push_sink("speaker", img.tobytes(), img.shape[1], img.shape[0], self.cfg.fps)
|
||||||
|
share = self._share_placeholder
|
||||||
|
self._push_sink("screenshare", share.tobytes(), share.shape[1], share.shape[0], self.cfg.fps)
|
||||||
|
next_spk = now + spk_period
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
|
||||||
|
async def _pump_camera(self, track, identity: str) -> None:
|
||||||
|
import livekit.rtc as rtc
|
||||||
|
|
||||||
|
stream = rtc.VideoStream(track)
|
||||||
|
try:
|
||||||
|
async for event in stream:
|
||||||
|
frame = event.frame
|
||||||
|
if frame.width <= 0 or frame.height <= 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
converted = frame.convert(rtc.VideoBufferType.BGRA)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("%s: convert failed: %s", identity, exc)
|
||||||
|
continue
|
||||||
|
bgra = bgra_from_livekit(converted.data, converted.width, converted.height)
|
||||||
|
self.grid.set_frame(identity, bgra)
|
||||||
|
if identity == self.speaker_identity():
|
||||||
|
self._speaker_frame = bgra
|
||||||
|
finally:
|
||||||
|
await stream.aclose()
|
||||||
|
self.grid.clear(identity)
|
||||||
|
if identity == self.speaker_identity():
|
||||||
|
self._speaker_frame = None
|
||||||
|
|
||||||
|
def _track_key(self, participant_identity: str, track) -> str:
|
||||||
|
return f"{participant_identity}:{getattr(track, 'sid', id(track))}"
|
||||||
|
|
||||||
|
def attach(self, track, publication, participant, rtc) -> None:
|
||||||
|
ident = participant.identity
|
||||||
|
if ident == self.cfg.display_identity:
|
||||||
|
return
|
||||||
|
key = self._track_key(ident, track)
|
||||||
|
old = self._tasks.pop(key, None)
|
||||||
|
if old:
|
||||||
|
old.cancel()
|
||||||
|
# Screenshare subscribe/render is stubbed until production LiveKit.
|
||||||
|
if is_camera_identity(ident, self.cfg.participant_prefix):
|
||||||
|
self._tasks[key] = asyncio.create_task(
|
||||||
|
self._pump_camera(track, ident), name=f"cam-{ident}")
|
||||||
|
|
||||||
|
def subscribe_if_wanted(self, participant, rtc) -> None:
|
||||||
|
ident = participant.identity
|
||||||
|
if ident == self.cfg.display_identity:
|
||||||
|
return
|
||||||
|
cam = is_camera_identity(ident, self.cfg.participant_prefix)
|
||||||
|
for pub in participant.track_publications.values():
|
||||||
|
if pub.kind != rtc.TrackKind.KIND_VIDEO:
|
||||||
|
continue
|
||||||
|
want = cam
|
||||||
|
if want and not pub.subscribed:
|
||||||
|
log.info("subscribe %s from %s (source=%s)", pub.sid, ident, pub.source)
|
||||||
|
pub.set_subscribed(True)
|
||||||
|
|
||||||
|
def detach(self, track, participant) -> None:
|
||||||
|
key = self._track_key(participant.identity, track)
|
||||||
|
task = self._tasks.pop(key, None)
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
async def run(self) -> int:
|
||||||
|
import livekit.rtc as rtc
|
||||||
|
|
||||||
|
room = rtc.Room()
|
||||||
|
|
||||||
|
@room.on("track_subscribed")
|
||||||
|
def _on_sub(track, publication, participant):
|
||||||
|
if track.kind == rtc.TrackKind.KIND_VIDEO:
|
||||||
|
log.info("subscribed %s from %s", publication.sid, participant.identity)
|
||||||
|
self.attach(track, publication, participant, rtc)
|
||||||
|
|
||||||
|
@room.on("track_unsubscribed")
|
||||||
|
def _on_unsub(track, publication, participant):
|
||||||
|
self.detach(track, participant)
|
||||||
|
|
||||||
|
@room.on("track_published")
|
||||||
|
def _on_pub(publication, participant):
|
||||||
|
self.subscribe_if_wanted(participant, rtc)
|
||||||
|
|
||||||
|
@room.on("participant_connected")
|
||||||
|
def _on_pc(participant):
|
||||||
|
log.info("participant joined: %s", participant.identity)
|
||||||
|
self.subscribe_if_wanted(participant, rtc)
|
||||||
|
|
||||||
|
@room.on("active_speakers_changed")
|
||||||
|
def _on_spk(speakers):
|
||||||
|
cams = [
|
||||||
|
p.identity for p in speakers
|
||||||
|
if is_camera_identity(p.identity, self.cfg.participant_prefix)
|
||||||
|
]
|
||||||
|
self.active_speaker = cams[0] if cams else None
|
||||||
|
|
||||||
|
token = make_viewer_token(
|
||||||
|
self.cfg.api_key, self.cfg.api_secret, self.cfg.room, self.cfg.display_identity)
|
||||||
|
log.info("connecting to %s room=%s as %s", self.cfg.url, self.cfg.room, self.cfg.display_identity)
|
||||||
|
await room.connect(self.cfg.url, token, rtc.RoomOptions(auto_subscribe=False))
|
||||||
|
|
||||||
|
for rp in room.remote_participants.values():
|
||||||
|
self.subscribe_if_wanted(rp, rtc)
|
||||||
|
for pub in rp.track_publications.values():
|
||||||
|
if pub.track and pub.track.kind == rtc.TrackKind.KIND_VIDEO:
|
||||||
|
self.attach(pub.track, pub, rp, rtc)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"wall up: grid=%dx%d speaker_camera=%s screenshare=STUB",
|
||||||
|
self.grid.cols, self.grid.rows, self.speaker_camera,
|
||||||
|
)
|
||||||
|
loops = asyncio.create_task(self._output_loops(), name="output-loops")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(sig, self._stop.set)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await self._stop.wait()
|
||||||
|
finally:
|
||||||
|
loops.cancel()
|
||||||
|
for t in self._tasks.values():
|
||||||
|
t.cancel()
|
||||||
|
for sink in self.slots.values():
|
||||||
|
sink.stop()
|
||||||
|
await room.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_test_pattern(slots: list[tuple[str, DrmOutput]]) -> int:
|
||||||
|
sinks = []
|
||||||
|
for i, (role, out) in enumerate(slots):
|
||||||
|
sink = KmsPipeline(out, role)
|
||||||
|
sink.start_test_pattern(_PATTERNS[i % len(_PATTERNS)])
|
||||||
|
sinks.append(sink)
|
||||||
|
log.info("test patterns on %d sink(s); Ctrl-C to stop", sum(1 for s in sinks if s.alive))
|
||||||
|
stop = False
|
||||||
|
|
||||||
|
def _stop(*_):
|
||||||
|
nonlocal stop
|
||||||
|
stop = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
|
try:
|
||||||
|
while not stop:
|
||||||
|
for s in sinks:
|
||||||
|
err = s.gst_errors()
|
||||||
|
if err:
|
||||||
|
for line in err.strip().splitlines()[-8:]:
|
||||||
|
log.warning("%s gst: %s", s.identity, line)
|
||||||
|
if any(s.out.connected and not s.alive and s.mode == "dead" for s in sinks):
|
||||||
|
return 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
for s in sinks:
|
||||||
|
s.stop()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--list", action="store_true", help="list DRM connectors and exit")
|
||||||
|
ap.add_argument("--test-pattern", action="store_true",
|
||||||
|
help="drive displays with GStreamer videotestsrc (no LiveKit)")
|
||||||
|
ap.add_argument("--connectors", default="",
|
||||||
|
help="comma-separated DRM names, e.g. HDMI-A-7,HDMI-A-5,HDMI-A-6")
|
||||||
|
ap.add_argument("--roles", default="",
|
||||||
|
help="comma-separated roles (default grid,speaker,screenshare)")
|
||||||
|
ap.add_argument("--speaker-camera", default="",
|
||||||
|
help="cam-NN or 'active' (overrides DISPLAY_SPEAKER_CAMERA)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [display] %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print(format_outputs(list_outputs()) or "(no DRM connectors)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
logging.getLogger().setLevel(getattr(logging, cfg.log_level, logging.INFO))
|
||||||
|
if args.speaker_camera:
|
||||||
|
cfg.display_speaker_camera = args.speaker_camera
|
||||||
|
roles = [p.strip() for p in args.roles.split(",") if p.strip()] or cfg.display_roles
|
||||||
|
connectors = [p.strip() for p in args.connectors.split(",") if p.strip()] or cfg.display_connectors
|
||||||
|
try:
|
||||||
|
slots = _bind_roles(roles, connectors)
|
||||||
|
except KeyError as exc:
|
||||||
|
log.error("%s", exc)
|
||||||
|
return 2
|
||||||
|
if not slots:
|
||||||
|
log.error("no DRM outputs to drive")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.test_pattern:
|
||||||
|
return run_test_pattern(slots)
|
||||||
|
return asyncio.run(Wall(cfg, slots).run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+80
-13
@@ -15,12 +15,19 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
log = logging.getLogger("cameras.worker")
|
log = logging.getLogger("cameras.worker")
|
||||||
|
|
||||||
|
|
||||||
|
def drain_held_frames(pending: deque, now: float, hold_s: float):
|
||||||
|
"""Yield frame payloads captured at least hold_s seconds ago."""
|
||||||
|
while pending and (now - pending[0][0]) >= hold_s:
|
||||||
|
yield pending.popleft()[1]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--camera-json", required=True,
|
ap.add_argument("--camera-json", required=True,
|
||||||
@@ -28,9 +35,13 @@ def main() -> int:
|
|||||||
ap.add_argument("--room", required=True)
|
ap.add_argument("--room", required=True)
|
||||||
ap.add_argument("--url", required=True)
|
ap.add_argument("--url", required=True)
|
||||||
ap.add_argument("--token", required=True)
|
ap.add_argument("--token", required=True)
|
||||||
ap.add_argument("--width", type=int, default=1280)
|
ap.add_argument("--width", type=int, default=640)
|
||||||
ap.add_argument("--height", type=int, default=720)
|
ap.add_argument("--height", type=int, default=360)
|
||||||
ap.add_argument("--fps", type=int, default=30)
|
ap.add_argument("--fps", type=int, default=30)
|
||||||
|
ap.add_argument("--bitrate", type=int, default=800_000)
|
||||||
|
ap.add_argument("--video-codec", default="h264")
|
||||||
|
ap.add_argument("--video-encoder", default="vaapi")
|
||||||
|
ap.add_argument("--vaapi-device", default="/dev/dri/renderD129")
|
||||||
ap.add_argument("--audio-rate", type=int, default=16000)
|
ap.add_argument("--audio-rate", type=int, default=16000)
|
||||||
ap.add_argument("--enhance-mode", default="auto")
|
ap.add_argument("--enhance-mode", default="auto")
|
||||||
ap.add_argument("--enhance-model",
|
ap.add_argument("--enhance-model",
|
||||||
@@ -56,6 +67,9 @@ def main() -> int:
|
|||||||
identity = cam["identity"]
|
identity = cam["identity"]
|
||||||
video_dev = cam["video_device"]
|
video_dev = cam["video_device"]
|
||||||
audio_card = cam.get("audio_card")
|
audio_card = cam.get("audio_card")
|
||||||
|
if args.vaapi_device:
|
||||||
|
os.environ.setdefault("LIBVA_DRM_DEVICE", args.vaapi_device)
|
||||||
|
os.environ.setdefault("LIBVA_DRIVER_NAME", "iHD")
|
||||||
|
|
||||||
async def run() -> int:
|
async def run() -> int:
|
||||||
cleaner = AudioCleaner(
|
cleaner = AudioCleaner(
|
||||||
@@ -81,29 +95,38 @@ def main() -> int:
|
|||||||
|
|
||||||
vproc = subprocess.Popen(
|
vproc = subprocess.Popen(
|
||||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||||
|
"-thread_queue_size", "4",
|
||||||
"-f", "v4l2",
|
"-f", "v4l2",
|
||||||
"-input_format", "mjpeg",
|
"-input_format", "mjpeg",
|
||||||
"-video_size", f"{args.width}x{args.height}",
|
"-video_size", f"{args.width}x{args.height}",
|
||||||
"-framerate", str(args.fps),
|
"-framerate", str(args.fps),
|
||||||
"-i", video_dev,
|
"-i", video_dev,
|
||||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
"-f", "rawvideo", "-pix_fmt", "yuv420p",
|
||||||
"pipe:1"],
|
"pipe:1"],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
frame_bytes = args.width * args.height * 4
|
frame_bytes = args.width * args.height * 3 // 2
|
||||||
video_frames = 0
|
video_frames = 0
|
||||||
|
# Audio is published in enhance hops (default 200 ms). Hold video by
|
||||||
|
# that same amount so lipsync at the subscriber: a frame captured at
|
||||||
|
# T is sent when the audio hop covering T is sent.
|
||||||
|
video_hold_s = float(args.enhance_hop_s) if audio_card is not None else 0.0
|
||||||
|
pending: deque[tuple[float, bytes]] = deque()
|
||||||
|
max_pending = max(8, int(args.fps * (video_hold_s + 0.15)) + 4)
|
||||||
|
if video_hold_s > 0:
|
||||||
|
log.info("av sync: holding video %.0f ms to match audio hop",
|
||||||
|
video_hold_s * 1000.0)
|
||||||
|
|
||||||
async def video_loop():
|
async def video_loop():
|
||||||
nonlocal video_frames
|
nonlocal video_frames
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
last = time.monotonic()
|
|
||||||
while vproc.poll() is None:
|
while vproc.poll() is None:
|
||||||
data = await loop.run_in_executor(
|
data = await loop.run_in_executor(
|
||||||
None, vproc.stdout.read, frame_bytes)
|
None, vproc.stdout.read, frame_bytes)
|
||||||
if not data or len(data) < frame_bytes:
|
if not data or len(data) < frame_bytes:
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
# partial read: accumulate (v4l2/ffmpeg shouldn't do this)
|
|
||||||
keep = b""
|
keep = b""
|
||||||
while len(keep) < frame_bytes:
|
while len(keep) < frame_bytes:
|
||||||
more = await loop.run_in_executor(
|
more = await loop.run_in_executor(
|
||||||
@@ -114,13 +137,16 @@ def main() -> int:
|
|||||||
keep += more
|
keep += more
|
||||||
data = keep
|
data = keep
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
ts_us = int((now - last) * 1_000_000)
|
pending.append((now, data))
|
||||||
|
while len(pending) > max_pending:
|
||||||
|
pending.popleft()
|
||||||
|
for payload in drain_held_frames(pending, now, video_hold_s):
|
||||||
try:
|
try:
|
||||||
video_source.capture_frame(
|
video_source.capture_frame(
|
||||||
rtc.VideoFrame(
|
rtc.VideoFrame(
|
||||||
args.width, args.height,
|
args.width, args.height,
|
||||||
rtc.VideoBufferType.BGRA, data),
|
rtc.VideoBufferType.I420, payload),
|
||||||
timestamp_us=ts_us,
|
timestamp_us=int(time.monotonic() * 1_000_000),
|
||||||
)
|
)
|
||||||
video_frames += 1
|
video_frames += 1
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -131,7 +157,8 @@ def main() -> int:
|
|||||||
atrack: rtc.LocalAudioTrack | None = None
|
atrack: rtc.LocalAudioTrack | None = None
|
||||||
if audio_card is not None:
|
if audio_card is not None:
|
||||||
audio_source = rtc.AudioSource(
|
audio_source = rtc.AudioSource(
|
||||||
sample_rate=args.audio_rate, num_channels=1)
|
sample_rate=args.audio_rate, num_channels=1,
|
||||||
|
queue_size_ms=max(250, int(args.enhance_hop_s * 1000) + 50))
|
||||||
atrack = rtc.LocalAudioTrack.create_audio_track(
|
atrack = rtc.LocalAudioTrack.create_audio_track(
|
||||||
f"{identity}-audio", audio_source)
|
f"{identity}-audio", audio_source)
|
||||||
|
|
||||||
@@ -180,7 +207,7 @@ def main() -> int:
|
|||||||
aproc = subprocess.Popen(
|
aproc = subprocess.Popen(
|
||||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||||
"-thread_queue_size", "64",
|
"-thread_queue_size", "8",
|
||||||
"-f", "alsa", "-i", f"hw:{audio_card},0",
|
"-f", "alsa", "-i", f"hw:{audio_card},0",
|
||||||
"-ar", str(args.audio_rate), "-ac", "2",
|
"-ar", str(args.audio_rate), "-ac", "2",
|
||||||
"-f", "s16le", "pipe:1"],
|
"-f", "s16le", "pipe:1"],
|
||||||
@@ -188,9 +215,49 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---------------- publish ----------------
|
# ---------------- publish ----------------
|
||||||
from livekit.rtc import TrackSource, TrackPublishOptions
|
from livekit.rtc import TrackSource, TrackPublishOptions, VideoEncoding, VideoCodec
|
||||||
|
from livekit.rtc._proto.room_pb2 import (
|
||||||
|
ENCODER_BACKEND_AUTO,
|
||||||
|
ENCODER_BACKEND_SOFTWARE,
|
||||||
|
ENCODER_BACKEND_HARDWARE,
|
||||||
|
ENCODER_BACKEND_NVENC,
|
||||||
|
ENCODER_BACKEND_VAAPI,
|
||||||
|
ENCODER_BACKEND_VIDEOTOOLBOX,
|
||||||
|
DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION,
|
||||||
|
)
|
||||||
|
encoder_map = {
|
||||||
|
"auto": ENCODER_BACKEND_AUTO,
|
||||||
|
"software": ENCODER_BACKEND_SOFTWARE,
|
||||||
|
"hardware": ENCODER_BACKEND_HARDWARE,
|
||||||
|
"nvenc": ENCODER_BACKEND_NVENC,
|
||||||
|
"vaapi": ENCODER_BACKEND_VAAPI,
|
||||||
|
"videotoolbox": ENCODER_BACKEND_VIDEOTOOLBOX,
|
||||||
|
}
|
||||||
|
codec_map = {
|
||||||
|
"vp8": VideoCodec.VP8,
|
||||||
|
"h264": VideoCodec.H264,
|
||||||
|
"av1": VideoCodec.AV1,
|
||||||
|
"vp9": VideoCodec.VP9,
|
||||||
|
"h265": VideoCodec.H265,
|
||||||
|
}
|
||||||
|
encoder = encoder_map.get(args.video_encoder, ENCODER_BACKEND_VAAPI)
|
||||||
|
codec = codec_map.get(args.video_codec, VideoCodec.H264)
|
||||||
pub_opts = TrackPublishOptions(
|
pub_opts = TrackPublishOptions(
|
||||||
source=TrackSource.SOURCE_CAMERA, simulcast=False)
|
source=TrackSource.SOURCE_CAMERA,
|
||||||
|
simulcast=False,
|
||||||
|
video_codec=codec,
|
||||||
|
video_encoding=VideoEncoding(
|
||||||
|
max_bitrate=int(args.bitrate),
|
||||||
|
max_framerate=int(args.fps),
|
||||||
|
),
|
||||||
|
video_encoder=encoder,
|
||||||
|
degradation_preference=DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"publish video %dx%d@%d %s encoder=%s bitrate=%d vaapi=%s",
|
||||||
|
args.width, args.height, args.fps, args.video_codec,
|
||||||
|
args.video_encoder, args.bitrate, args.vaapi_device,
|
||||||
|
)
|
||||||
await room.local_participant.publish_track(video_track, pub_opts)
|
await room.local_participant.publish_track(video_track, pub_opts)
|
||||||
if atrack is not None:
|
if atrack is not None:
|
||||||
await room.local_participant.publish_track(
|
await room.local_participant.publish_track(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print a compact DRM connector/CRTC/plane map from drm_info -j."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = subprocess.check_output(["drm_info", "-j"], text=True)
|
||||||
|
data = json.loads(raw)
|
||||||
|
for node, info in data.items():
|
||||||
|
driver = info.get("driver", {}).get("name") or info.get("driver_name")
|
||||||
|
device = info.get("device", {})
|
||||||
|
print(f"NODE {node} driver={driver} device={device}")
|
||||||
|
conns = info.get("connectors") or info.get("Connector") or []
|
||||||
|
if isinstance(conns, dict):
|
||||||
|
conns = list(conns.values())
|
||||||
|
crtcs = info.get("crtcs") or []
|
||||||
|
planes = info.get("planes") or []
|
||||||
|
print(f" crtcs={len(crtcs)} planes={len(planes)} connectors={len(conns)}")
|
||||||
|
if conns and isinstance(conns[0], dict):
|
||||||
|
print(" connector keys:", sorted(conns[0].keys())[:20])
|
||||||
|
for c in conns:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
print(" conn", c)
|
||||||
|
continue
|
||||||
|
cid = c.get("id") or c.get("object_id")
|
||||||
|
typ = c.get("type") or c.get("connector_type")
|
||||||
|
name = c.get("name")
|
||||||
|
st = c.get("status")
|
||||||
|
modes = c.get("modes") or []
|
||||||
|
mode0 = None
|
||||||
|
if modes:
|
||||||
|
m = modes[0]
|
||||||
|
if isinstance(m, dict):
|
||||||
|
mode0 = f"{m.get('name') or m.get('clock')} {m.get('hdisplay')}x{m.get('vdisplay')}"
|
||||||
|
else:
|
||||||
|
mode0 = str(m)[:80]
|
||||||
|
print(f" connector id={cid} type={typ} name={name} status={st} mode0={mode0}")
|
||||||
|
if planes and isinstance(planes[0], dict):
|
||||||
|
print(" plane keys:", sorted(planes[0].keys())[:20])
|
||||||
|
for p in planes[:8]:
|
||||||
|
print(" plane", {k: p.get(k) for k in list(p)[:12]})
|
||||||
|
if crtcs and isinstance(crtcs[0], dict):
|
||||||
|
print(" crtc keys:", sorted(crtcs[0].keys())[:20])
|
||||||
|
for cr in crtcs:
|
||||||
|
print(" crtc", {k: cr.get(k) for k in list(cr)[:12]})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print a LiveKit Meet URL + token so a speaker can join and share a screen.
|
||||||
|
|
||||||
|
STUB / future: do not use until the production LiveKit server is online.
|
||||||
|
The third display currently shows a placeholder only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from tokens import make_speaker_token
|
||||||
|
|
||||||
|
|
||||||
|
def public_url(cfg) -> str:
|
||||||
|
if cfg.livekit_public_url:
|
||||||
|
return cfg.livekit_public_url.rstrip("/")
|
||||||
|
parsed = urlparse(cfg.url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
if host in ("127.0.0.1", "localhost"):
|
||||||
|
host = "10.200.0.20"
|
||||||
|
scheme = parsed.scheme or "ws"
|
||||||
|
port = parsed.port
|
||||||
|
if port:
|
||||||
|
return f"{scheme}://{host}:{port}"
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--identity", default="", help="default: DISPLAY_SPEAKER_PARTICIPANT")
|
||||||
|
ap.add_argument("--name", default="Speaker")
|
||||||
|
args = ap.parse_args()
|
||||||
|
cfg = load_config()
|
||||||
|
identity = args.identity or cfg.display_speaker_participant or "speaker"
|
||||||
|
token = make_speaker_token(cfg.api_key, cfg.api_secret, cfg.room, identity, args.name)
|
||||||
|
url = public_url(cfg)
|
||||||
|
meet = (
|
||||||
|
"https://meet.livekit.io/custom"
|
||||||
|
f"?liveKitUrl={quote(url, safe='')}&token={quote(token, safe='')}"
|
||||||
|
)
|
||||||
|
print(f"room: {cfg.room}")
|
||||||
|
print(f"identity: {identity}")
|
||||||
|
print(f"url: {url}")
|
||||||
|
print(f"token: {token}")
|
||||||
|
print()
|
||||||
|
print("Open this on the speaker laptop, then Share screen:")
|
||||||
|
print(meet)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""A/V hold queue: video is delayed to match the audio hop."""
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from run_worker import drain_held_frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_nothing_until_hold():
|
||||||
|
q = deque([(0.0, b"a"), (0.05, b"b")])
|
||||||
|
assert list(drain_held_frames(q, now=0.1, hold_s=0.2)) == []
|
||||||
|
assert len(q) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_ready_frames():
|
||||||
|
q = deque([(0.0, b"a"), (0.05, b"b"), (0.25, b"c")])
|
||||||
|
got = list(drain_held_frames(q, now=0.26, hold_s=0.2))
|
||||||
|
assert got == [b"a", b"b"]
|
||||||
|
assert list(q) == [(0.25, b"c")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_hold_drains_all():
|
||||||
|
q = deque([(1.0, b"x")])
|
||||||
|
assert list(drain_held_frames(q, now=1.0, hold_s=0.0)) == [b"x"]
|
||||||
|
assert not q
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""5x4 camera mosaic helpers."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from display_grid import (
|
||||||
|
CameraGrid,
|
||||||
|
camera_ids,
|
||||||
|
display_name,
|
||||||
|
is_camera_identity,
|
||||||
|
letterbox_bgra,
|
||||||
|
placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_ids():
|
||||||
|
ids = camera_ids(20)
|
||||||
|
assert ids[0] == "cam-01" and ids[-1] == "cam-20"
|
||||||
|
assert len(ids) == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_camera_identity():
|
||||||
|
assert is_camera_identity("cam-07")
|
||||||
|
assert is_camera_identity("cam-07", "cam")
|
||||||
|
assert not is_camera_identity("speaker")
|
||||||
|
assert not is_camera_identity("display-wall")
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_name():
|
||||||
|
assert display_name("cam-07") == "CAM-07"
|
||||||
|
assert display_name("cam-01") == "CAM-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_letterbox_preserves_canvas_size():
|
||||||
|
src = np.zeros((720, 1280, 4), dtype=np.uint8)
|
||||||
|
src[:] = (0, 0, 255, 255)
|
||||||
|
out = letterbox_bgra(src, 384, 270)
|
||||||
|
assert out.shape == (270, 384, 4)
|
||||||
|
assert out.dtype == np.uint8
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_render_size_and_slots():
|
||||||
|
g = CameraGrid(width=1920, height=1080, cols=5, rows=4)
|
||||||
|
assert g.tile_w == (1920 - g.gap * 6) // 5
|
||||||
|
assert g.tile_h == (1080 - g.gap * 5) // 4
|
||||||
|
assert g.inner_w < g.tile_w
|
||||||
|
assert g.inner_h < g.tile_h
|
||||||
|
assert len(g.identities) == 20
|
||||||
|
buf = g.render()
|
||||||
|
assert len(buf) == 1920 * 1080 * 4
|
||||||
|
frame = np.zeros((720, 1280, 4), dtype=np.uint8)
|
||||||
|
frame[:] = (255, 0, 0, 255)
|
||||||
|
g.set_frame("cam-07", frame)
|
||||||
|
buf2 = g.render()
|
||||||
|
assert buf2 != buf
|
||||||
|
g.clear("cam-07")
|
||||||
|
assert len(g.render()) == len(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_tiles_have_name_placeholder():
|
||||||
|
g = CameraGrid(width=1920, height=1080, cols=5, rows=4)
|
||||||
|
# Name is drawn in the inner region — not a flat fill.
|
||||||
|
tile = g._tiles["cam-01"]
|
||||||
|
assert tile.shape == (g.tile_h, g.tile_w, 4)
|
||||||
|
assert tile.sum() > 0
|
||||||
|
inner = tile[g.border : g.border + g.inner_h, g.border : g.border + g.inner_w]
|
||||||
|
# Center of an empty tile is not uniform (text glyphs).
|
||||||
|
assert inner.std() > 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_separates_tiles():
|
||||||
|
g = CameraGrid(width=1920, height=1080, cols=5, rows=4, gap=8)
|
||||||
|
canvas = np.frombuffer(g.render(), dtype=np.uint8).reshape(1080, 1920, 4)
|
||||||
|
# Pixel in the gutter between col 0 and col 1 is canvas background, not a tile.
|
||||||
|
x = g.gap + g.tile_w + g.gap // 2
|
||||||
|
y = g.gap + 4
|
||||||
|
assert tuple(canvas[y, x]) == (10, 10, 10, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_draws_text():
|
||||||
|
img = placeholder(640, 360, ["Screen share", "stub"])
|
||||||
|
assert img.shape == (360, 640, 4)
|
||||||
|
assert img.sum() > 0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""DRM connector discovery for the 3-display LiveKit wall."""
|
||||||
|
from drm_outputs import list_outputs, select_outputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_lists_arc_hdmi():
|
||||||
|
outs = list_outputs()
|
||||||
|
assert outs, "expected at least one DRM connector"
|
||||||
|
names = {o.name for o in outs}
|
||||||
|
assert "HDMI-A-7" in names
|
||||||
|
hdmi7 = next(o for o in outs if o.name == "HDMI-A-7")
|
||||||
|
assert hdmi7.connector_id > 0
|
||||||
|
assert hdmi7.driver in ("xe", "i915")
|
||||||
|
assert hdmi7.node.startswith("/dev/dri/card")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_three_prefers_xe_hdmi():
|
||||||
|
three = select_outputs(n=3)
|
||||||
|
assert len(three) == 3
|
||||||
|
assert three[0].name == "HDMI-A-7" or three[0].connected
|
||||||
|
ids = [o.connector_id for o in three]
|
||||||
|
assert len(set(ids)) == 3
|
||||||
@@ -7,4 +7,4 @@ def test_alsa_ffmpeg_uses_low_delay_flags():
|
|||||||
src = inspect.getsource(run_worker)
|
src = inspect.getsource(run_worker)
|
||||||
assert '"-fflags", "nobuffer"' in src
|
assert '"-fflags", "nobuffer"' in src
|
||||||
assert '"-flags", "low_delay"' in src
|
assert '"-flags", "low_delay"' in src
|
||||||
assert '"-thread_queue_size", "64"' in src
|
assert '"-thread_queue_size"' in src
|
||||||
|
|||||||
@@ -18,3 +18,40 @@ def make_token(url: str, api_key: str, api_secret: str,
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
|
def make_speaker_token(api_key: str, api_secret: str,
|
||||||
|
room: str, identity: str = "speaker",
|
||||||
|
name: str = "") -> str:
|
||||||
|
"""Publisher token so a speaker can join and share their screen."""
|
||||||
|
token = (
|
||||||
|
AccessToken(api_key, api_secret)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name(name or identity)
|
||||||
|
.with_grants(VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room,
|
||||||
|
can_publish=True,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_publish_data=True,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
|
def make_viewer_token(api_key: str, api_secret: str,
|
||||||
|
room: str, identity: str) -> str:
|
||||||
|
"""Subscribe-only token for headless display clients."""
|
||||||
|
token = (
|
||||||
|
AccessToken(api_key, api_secret)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name(identity)
|
||||||
|
.with_grants(VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room,
|
||||||
|
can_publish=False,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_publish_data=False,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
|
|||||||
Reference in New Issue
Block a user