kmsgrab e ScreenShot2: esiti sperimentali documentati
- kmsgrab non praticabile: piano primario AR30 con modifier AMD DCC/tiled, ffmpeg rifiuta il framebuffer prima di applicare format/modifier - KWin ScreenShot2: NoAuthorized anche con desktop entry system-wide - indicatore del vassoio identificato come SNI di xdg-desktop-portal-kde (titolo 'Trasmissione dello schermo') - backend kmsgrab comunque implementato e selezionabile (capture=kmsgrab) - setup-kmsgrab.sh: installa via pkexec una copia di ffmpeg con cap_sys_admin+ep
This commit is contained in:
@@ -88,6 +88,9 @@ La scheda (CH340, `1a86:7523`) **non espone un iSerial**: verificato con
|
|||||||
| `/dev/serial/by-path/pci-0000:65:00.3-usb-0:2:1.0-port0` | legato alla porta USB fisica | sì |
|
| `/dev/serial/by-path/pci-0000:65:00.3-usb-0:2:1.0-port0` | legato alla porta USB fisica | sì |
|
||||||
| `/dev/ambilight` (regola udev) | symlink creato da noi | sì |
|
| `/dev/ambilight` (regola udev) | symlink creato da noi | sì |
|
||||||
|
|
||||||
|
> **Su questa macchina la regola NON è installata**: scelta esplicita dell'utente di
|
||||||
|
> lasciare `ttyUSB0` come assegnato dal kernel. I file restano nel repo come opzione.
|
||||||
|
|
||||||
Installa la regola (richiede sudo, una volta):
|
Installa la regola (richiede sudo, una volta):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -117,17 +120,30 @@ finché la sessione di condivisione è attiva. Verificato su questa macchina:
|
|||||||
esiste la voce "Configure System Tray → Entries" per nasconderlo: è un indicatore
|
esiste la voce "Configure System Tray → Entries" per nasconderlo: è un indicatore
|
||||||
di stato del shell, per scelta di KDE.
|
di stato del shell, per scelta di KDE.
|
||||||
|
|
||||||
|
**Il pallino è uno StatusNotifierItem pubblicato da `xdg-desktop-portal-kde`** (titolo
|
||||||
|
"Trasmissione dello schermo"): quindi è un elemento del vassoio con visibilità
|
||||||
|
configurabile da *Configura il vassoio di sistema → Voci* (impostandolo su "Sempre
|
||||||
|
nascosto" sparisce dal pannello e resta nel popup espanso).
|
||||||
|
|
||||||
Le alternative per non averlo:
|
Le alternative per non averlo:
|
||||||
|
|
||||||
1. **KWin ScreenShot2** (nessun portal, nessun indicatore): KWin autorizza solo i
|
1. **KWin ScreenShot2** (nessun portal, nessun indicatore) — **rifiutato su questa
|
||||||
|
macchina**: testato sia con `.desktop` utente sia con entry system-wide in
|
||||||
|
`/usr/local/share/applications` (`Exec=/usr/bin/python3`, `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`,
|
||||||
|
`kbuildsycoca6`): la risposta resta `org.kde.KWin.ScreenShot2.Error.NoAuthorized`. KWin autorizza solo i
|
||||||
processi con un `.desktop` che dichiara `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`.
|
processi con un `.desktop` che dichiara `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`.
|
||||||
Provato con un `.desktop` utente (`Exec=/usr/bin/python3` e `python3.14`) + `kbuildsycoca6`:
|
Provato con un `.desktop` utente (`Exec=/usr/bin/python3` e `python3.14`) + `kbuildsycoca6`:
|
||||||
risponde ancora `org.kde.KWin.ScreenShot2.Error.NoAuthorized`. Non provato l'entry
|
risponde ancora `org.kde.KWin.ScreenShot2.Error.NoAuthorized`. Non provato l'entry
|
||||||
in `/usr/local/share/applications` (richiede sudo). Se funziona, la cattura è un
|
in `/usr/local/share/applications` (richiede sudo). Se funziona, la cattura è un
|
||||||
`CaptureArea` della sola fascia (nessun indicatore, ma costo CPU per frame).
|
`CaptureArea` della sola fascia (nessun indicatore, ma costo CPU per frame).
|
||||||
2. **kmsgrab** (`ffmpeg -f kmsgrab`) da un servizio privilegiato con `CAP_SYS_ADMIN`:
|
2. **kmsgrab** (`ffmpeg -f kmsgrab`) con `CAP_SYS_ADMIN`: **provato e non praticabile su
|
||||||
nessun portal e nessun indicatore, ma cattura DRM grezza (piano/format legati alla
|
questa macchina** (Radeon 780M + KWin). Il piano primario è `plane-3` (id 206),
|
||||||
GPU) e un servizio di sistema: più fragile.
|
`crtc-0`, `fb=463`, `format=AR30` (`0x30335241`) con `modifier=0x200000010437b04`
|
||||||
|
(AMD DCC/tiled): ffmpeg risponde `Framebuffer pixel format 30335241 is not a known
|
||||||
|
supported format` e fallisce **prima** di applicare `-format`/`-format_modifier`
|
||||||
|
(provati `bgr0`, `rgb0`, `0rgb`, `bgra`, `x2rgb10le`, `-format_modifier 0`,
|
||||||
|
`-plane_id 206`). Anche superando il controllo, il layout tiled/DCC richiederebbe
|
||||||
|
un import GBM/EGL o VAAPI, che kmsgrab non fa.
|
||||||
3. Tenere il portal: costo GPU minimo, ~78 fps, indicatore visibile.
|
3. Tenere il portal: costo GPU minimo, ~78 fps, indicatore visibile.
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|||||||
@@ -113,3 +113,89 @@ class Capture:
|
|||||||
if len(data) < need:
|
if len(data) < need:
|
||||||
return None
|
return None
|
||||||
return np.frombuffer(data, dtype=np.uint8).reshape(self.gh, self.gw, 3)
|
return np.frombuffer(data, dtype=np.uint8).reshape(self.gh, self.gw, 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ kmsgrab
|
||||||
|
FFMPEG_CAP = "/usr/local/libexec/ambilight/ffmpeg-kmsgrab"
|
||||||
|
FFMPEG_SYS = "/usr/bin/ffmpeg"
|
||||||
|
|
||||||
|
|
||||||
|
class KmsgrabCapture:
|
||||||
|
"""Cattura DRM diretta (ffmpeg -f kmsgrab) senza portal e senza indicatore.
|
||||||
|
|
||||||
|
Richiede CAP_SYS_ADMIN sul binario ffmpeg di appoggio (FFMPEG_CAP), oppure root.
|
||||||
|
ffmpeg riduce il frame a (leds*sub) x rows in RGB e lo scrive su stdout come
|
||||||
|
rawvideo: leggiamo esattamente un frame per volta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, leds, sub=4, rows=72, fps=60, device="/dev/dri/card1",
|
||||||
|
ffmpeg=None):
|
||||||
|
self.gw = leds * sub
|
||||||
|
self.gh = rows
|
||||||
|
self.fps = max(1, int(fps))
|
||||||
|
self.device = device
|
||||||
|
ff = ffmpeg or (FFMPEG_CAP if os.path.exists(FFMPEG_CAP) else FFMPEG_SYS)
|
||||||
|
self.binary = ff
|
||||||
|
self.frame_bytes = self.gw * self.gh * 3
|
||||||
|
self.proc = None
|
||||||
|
self.stderr_path = os.path.join(CACHE_DIR, "kmsgrab.log")
|
||||||
|
# hwdownload porta il frame in RAM; lo scaling avviene poi in ffmpeg.
|
||||||
|
self.vf = (f"hwdownload,format=bgr0,"
|
||||||
|
f"scale={self.gw}:{self.gh}:flags=bilinear,format=rgb24")
|
||||||
|
self.cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||||
|
"-f", "kmsgrab", "-device", device, "-i", "-",
|
||||||
|
"-vf", self.vf,
|
||||||
|
"-r", str(self.fps),
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"]
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
import subprocess
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
self.err = open(self.stderr_path, "wb")
|
||||||
|
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE,
|
||||||
|
stderr=self.err, bufsize=self.frame_bytes)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
if self.proc is not None:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
self.proc.kill()
|
||||||
|
finally:
|
||||||
|
self.proc = None
|
||||||
|
try:
|
||||||
|
self.err.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def alive(self):
|
||||||
|
return self.proc is not None and self.proc.poll() is None
|
||||||
|
|
||||||
|
def frame(self, timeout_ms=200):
|
||||||
|
"""Legge un frame completo; None se non arriva entro il timeout."""
|
||||||
|
import select
|
||||||
|
import numpy as np
|
||||||
|
if self.proc is None:
|
||||||
|
return None
|
||||||
|
fd = self.proc.stdout
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = timeout_ms / 1000.0
|
||||||
|
while len(buf) < self.frame_bytes:
|
||||||
|
ready, _, _ = select.select([fd], [], [], deadline)
|
||||||
|
if not ready:
|
||||||
|
return None
|
||||||
|
chunk = fd.read(self.frame_bytes - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
buf += chunk
|
||||||
|
return np.frombuffer(bytes(buf), dtype=np.uint8).reshape(self.gh, self.gw, 3)
|
||||||
|
|
||||||
|
def error_text(self):
|
||||||
|
try:
|
||||||
|
with open(self.stderr_path, "rb") as f:
|
||||||
|
return f.read()[-400:].decode("utf-8", "replace").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|||||||
+33
-11
@@ -42,6 +42,8 @@ DEFAULTS = {
|
|||||||
"baud": 115200,
|
"baud": 115200,
|
||||||
"fps": 60,
|
"fps": 60,
|
||||||
"side": "top", # top | bottom
|
"side": "top", # top | bottom
|
||||||
|
"capture": "portal", # portal (ScreenCast) | kmsgrab (DRM diretto, senza indicatore)
|
||||||
|
"drm_device": "/dev/dri/card1",
|
||||||
"auto_start": True, # streaming attivo all'avvio del demone
|
"auto_start": True, # streaming attivo all'avvio del demone
|
||||||
}
|
}
|
||||||
PARAM_RANGES = {
|
PARAM_RANGES = {
|
||||||
@@ -190,32 +192,53 @@ def apply_params(zones, cfg):
|
|||||||
|
|
||||||
|
|
||||||
def capture_loop(state, strip):
|
def capture_loop(state, strip):
|
||||||
from ambicapture import Capture, screencast_stream
|
"""Ciclo di cattura: sceglie il backend (portal o kmsgrab) e invia i frame."""
|
||||||
|
from ambicapture import Capture, KmsgrabCapture, screencast_stream
|
||||||
|
|
||||||
cap = None
|
cap = None
|
||||||
leds_cfg = None
|
key = None
|
||||||
prev = None
|
prev = None
|
||||||
sent = 0
|
sent = 0
|
||||||
t_win = time.time()
|
t_win = time.time()
|
||||||
|
|
||||||
|
def build(cfg):
|
||||||
|
"""Crea e avvia il backend; ritorna (cap, descrizione)."""
|
||||||
|
if cfg["capture"] == "kmsgrab":
|
||||||
|
c = KmsgrabCapture(cfg["leds"], fps=cfg["fps"], device=cfg["drm_device"]).start()
|
||||||
|
return c, f"kmsgrab {c.binary} su {cfg['drm_device']}"
|
||||||
|
node, w, h = screencast_stream()
|
||||||
|
c = Capture(node, cfg["leds"], fps=cfg["fps"])
|
||||||
|
c.start()
|
||||||
|
return c, f"portal node={node} sorgente {w}x{h}"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
cfg = state.snapshot()
|
cfg = state.snapshot()
|
||||||
if not cfg["running"]:
|
if not cfg["running"]:
|
||||||
if cap is not None:
|
if cap is not None:
|
||||||
cap.stop()
|
cap.stop()
|
||||||
cap = None
|
cap = None
|
||||||
|
key = None
|
||||||
prev = None
|
prev = None
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cap is None or leds_cfg != cfg["leds"]:
|
want = (cfg["capture"], cfg["leds"], cfg["drm_device"], cfg["fps"])
|
||||||
|
dead = cap is not None and hasattr(cap, "alive") and not cap.alive()
|
||||||
|
if cap is None or want != key or dead:
|
||||||
if cap is not None:
|
if cap is not None:
|
||||||
|
err = cap.error_text() if hasattr(cap, "error_text") else ""
|
||||||
cap.stop()
|
cap.stop()
|
||||||
|
cap = None
|
||||||
|
if dead:
|
||||||
|
with state.lock:
|
||||||
|
state.stream_error = f"kmsgrab terminato: {err}"[:300]
|
||||||
|
log("kmsgrab terminato:", err)
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
node, w, h = screencast_stream()
|
cap, desc = build(cfg)
|
||||||
cap = Capture(node, cfg["leds"], fps=cfg["fps"])
|
key = want
|
||||||
cap.start()
|
log(f"cattura avviata ({cfg['capture']}): {desc} led={cfg['leds']}")
|
||||||
leds_cfg = cfg["leds"]
|
|
||||||
log(f"cattura avviata: node={node} sorgente {w}x{h} led={cfg['leds']}")
|
|
||||||
with state.lock:
|
with state.lock:
|
||||||
state.stream_error = ""
|
state.stream_error = ""
|
||||||
state.last_error = ""
|
state.last_error = ""
|
||||||
@@ -231,9 +254,8 @@ def capture_loop(state, strip):
|
|||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rows = max(1, int(round(cap.gh * cfg["band"])))
|
|
||||||
gh, gw = frame.shape[0], frame.shape[1]
|
gh, gw = frame.shape[0], frame.shape[1]
|
||||||
rows = min(rows, gh)
|
rows = min(max(1, int(round(gh * cfg["band"]))), gh)
|
||||||
band = frame[:rows] if cfg["side"] == "top" else frame[gh - rows:]
|
band = frame[:rows] if cfg["side"] == "top" else frame[gh - rows:]
|
||||||
sub = gw // cfg["leds"]
|
sub = gw // cfg["leds"]
|
||||||
if sub < 1:
|
if sub < 1:
|
||||||
@@ -358,7 +380,7 @@ class DBusService:
|
|||||||
value = bool(value)
|
value = bool(value)
|
||||||
elif key == "leds":
|
elif key == "leds":
|
||||||
value = max(1, min(256, int(value)))
|
value = max(1, min(256, int(value)))
|
||||||
elif key in ("port", "side", "baud", "fps"):
|
elif key in ("port", "side", "baud", "fps", "capture", "drm_device"):
|
||||||
value = type(DEFAULTS[key])(value)
|
value = type(DEFAULTS[key])(value)
|
||||||
else:
|
else:
|
||||||
return False, f"parametro sconosciuto: {key}"
|
return False, f"parametro sconosciuto: {key}"
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installa (via pkexec) una copia di ffmpeg con CAP_SYS_ADMIN, necessaria per kmsgrab.
|
||||||
|
# La capability e' confinata a QUELLA copia del binario, non a /usr/bin/ffmpeg.
|
||||||
|
#
|
||||||
|
# ./setup-kmsgrab.sh installa
|
||||||
|
# ./setup-kmsgrab.sh --remove rimuove
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEST="/usr/local/libexec/ambilight/ffmpeg-kmsgrab"
|
||||||
|
SRC="$(command -v ffmpeg)"
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--remove" ]; then
|
||||||
|
say "Rimuovo $DEST"
|
||||||
|
pkexec sh -c "rm -f '$DEST' && rmdir --ignore-fail-on-non-empty /usr/local/libexec/ambilight 2>/dev/null || true"
|
||||||
|
say "Fatto."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -x "$SRC" ] || { echo "ffmpeg non trovato" >&2; exit 1; }
|
||||||
|
|
||||||
|
say "Installo $SRC -> $DEST con cap_sys_admin+ep (pkexec chiedera' la password)"
|
||||||
|
pkexec sh -c "install -D -m 0755 -o root -g root '$SRC' '$DEST' \
|
||||||
|
&& setcap cap_sys_admin+ep '$DEST' \
|
||||||
|
&& echo '--- capability applicata:' && getcap '$DEST'"
|
||||||
|
|
||||||
|
say "Verifica rapida (1 frame, senza portal e senza indicatore nel vassoio)"
|
||||||
|
"$DEST" -hide_banner -loglevel error -nostdin \
|
||||||
|
-f kmsgrab -device /dev/dri/card1 -i - \
|
||||||
|
-vf "hwdownload,format=bgr0,scale=188:72:flags=bilinear,format=rgb24" \
|
||||||
|
-frames:v 1 -f rawvideo -pix_fmt rgb24 /tmp/kmsgrab_probe.rgb
|
||||||
|
ls -l /tmp/kmsgrab_probe.rgb
|
||||||
|
python3 - <<'PY'
|
||||||
|
import numpy as np
|
||||||
|
a = np.frombuffer(open('/tmp/kmsgrab_probe.rgb','rb').read(), dtype=np.uint8)
|
||||||
|
print("frame:", a.size, "byte ->", a[:9].tolist(), "| media:", a.reshape(-1,3).mean(axis=0).round(1).tolist())
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Attiva il backend nel demone:
|
||||||
|
|
||||||
|
ambilight-ctl set capture kmsgrab
|
||||||
|
systemctl --user restart ambilight.service
|
||||||
|
ambilight-ctl state # controlla 'fps' e assenza di 'stream_error'
|
||||||
|
|
||||||
|
Per tornare al portal: ambilight-ctl set capture portal
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user