diff --git a/bin/start_viewer.sh b/bin/start_viewer.sh index a83a6f5bd..4c371b5a4 100755 --- a/bin/start_viewer.sh +++ b/bin/start_viewer.sh @@ -116,25 +116,34 @@ fi # we just set; -E alone is subject to env_check / env_delete and is not # guaranteed for XDG_* on Debian's default sudoers. # -# x86 boards run under `cage`, a kiosk wlroots compositor, because -# balenaOS x86 doesn't expose /dev/fb0 (Qt's linuxfb plugin has nothing -# to draw to) and there's no host display server. cage acquires DRM -# master as root, exports WAYLAND_DISPLAY for its child, and exits when -# the child exits — so the existing kill -0 watchdog below still works. -# The inner sudo drops back to the viewer user; WAYLAND_DISPLAY has to -# be added to --preserve-env to survive sudo's env scrub. -if [ "$DEVICE_TYPE" = "x86" ] || [ "$DEVICE_TYPE" = "arm64" ]; then +# All Qt6 boards (pi4-64, pi5, x86, arm64) run under `cage`, a kiosk +# wlroots compositor. This is required on x86 (balenaOS doesn't expose +# /dev/fb0) and on generic arm64 (Armbian boots Mesa straight to KMS +# with no fbdev); the Pi boards historically used Qt linuxfb + mpv +# --vo=drm directly on KMS, but were consolidated onto the same +# Wayland path so the four Qt6 images share one display stack. cage +# acquires DRM master as root, exports WAYLAND_DISPLAY for its child, +# and exits when the child exits — so the existing kill -0 watchdog +# below still works. The inner sudo drops back to the viewer user; +# WAYLAND_DISPLAY has to be added to --preserve-env to survive sudo's +# env scrub. Qt5 boards (pi2/pi3) stay on the legacy linuxfb path. +case "$DEVICE_TYPE" in + x86|arm64|pi4-64|pi5) # /dev/dri/renderD128 carries the host's `render` group, whose # numeric GID is distro-dependent (typically 992 on Debian/Ubuntu, - # 109 elsewhere) and not present in the container's /etc/group. - # Without membership the `viewer` user can open card0 (group - # `video`, GID 44 — already a member) but not the render node, and - # VAAPI silently fails with "wayland: failed to open - # /dev/dri/renderD128". mpv then falls back to software decode and - # frames drop at 1080p on entry-level x86. Mirror the host GID - # into the container as a synthetic `host-render` group and add - # `viewer` to it, so the supplementary group list `sudo -u viewer` - # later resolves from /etc/group already includes render access. + # 109 elsewhere, 106 on Pi OS Bookworm) and not always present in + # the container's /etc/group. Without membership the `viewer` + # user can open card0 (group `video`, GID 44 — already a member) + # but not the render node. On x86 that means VAAPI silently fails + # with "wayland: failed to open /dev/dri/renderD128" and mpv + # falls back to software decode — frames drop at 1080p on + # entry-level x86. On Pi4-64 / Pi5 the GL context (V3D render + # node) and v4l2-request hwdec equivalents need the same access + # for --vo=gpu --gpu-context=wayland. Mirror the host GID into + # the container as a synthetic `host-render` group and add + # `viewer` to it, so the supplementary group list `sudo -u + # viewer` later resolves from /etc/group already includes render + # access. if [ -e /dev/dri/renderD128 ]; then render_gid=$(stat -c %g /dev/dri/renderD128) if [ "$render_gid" -ne 0 ]; then @@ -168,10 +177,12 @@ if [ "$DEVICE_TYPE" = "x86" ] || [ "$DEVICE_TYPE" = "arm64" ]; then -E -u viewer \ dbus-run-session /venv/bin/python -m anthias_viewer ' & -else + ;; + *) sudo --preserve-env=XDG_RUNTIME_DIR,QT_SCALE_FACTOR,PYTHONPATH,LANG,LANGUAGE,LC_ALL -E -u viewer \ dbus-run-session /venv/bin/python -m anthias_viewer & -fi + ;; +esac # Wait for the viewer while true; do diff --git a/docker/Dockerfile.viewer.j2 b/docker/Dockerfile.viewer.j2 index 7815b2504..57ad72e3e 100644 --- a/docker/Dockerfile.viewer.j2 +++ b/docker/Dockerfile.viewer.j2 @@ -68,13 +68,15 @@ COPY --from=webview-builder /out/bin/AnthiasWebview /usr/local/bin/AnthiasWebvie COPY --from=webview-builder /out/share/AnthiasWebview /usr/local/share/AnthiasWebview ENV QT_QPA_EGLFS_FORCE888=1 -{% if board in ('x86', 'arm64') %} -# balenaOS x86 doesn't expose /dev/fb0, so the linuxfb plugin has -# nothing to draw to. Run Qt under Wayland instead; bin/start_viewer.sh -# wraps the viewer in `cage`, a kiosk Wayland compositor that talks to -# KMS via wlroots. The same path covers arm64 boards (Armbian -# on Rock Pi / Orange Pi / Banana Pi, …), which similarly boot Mesa -# straight to DRM/KMS without a fbdev emulation node. +{% if is_qt6 %} +# All Qt6 boards run the viewer under `cage`, a kiosk Wayland +# compositor that talks to KMS via wlroots. bin/start_viewer.sh wraps +# the viewer process in cage; this env var tells Qt to load the +# qt6-wayland platform plugin so it renders into cage's surface. +# Covers x86 (balenaOS doesn't expose /dev/fb0), arm64 Armbian +# boards (Rock Pi / Orange Pi / Banana Pi, …), and the Pi4-64 / Pi5 +# (consolidated onto Wayland so all Qt6 boards share one display +# stack — see #TBD). ENV QT_QPA_PLATFORM=wayland {% else %} ENV QT_QPA_PLATFORM=linuxfb diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 4895174d6..33dfc04e8 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -205,39 +205,37 @@ def play(self) -> None: # effect without a viewer restart, matching VLCMediaPlayer. settings.load() - # Pin to 1080p on Pi4-64/Pi5: mpv's default --drm-mode=preferred - # reads the connector's EDID-preferred mode (4K on most modern - # TVs) and runs CPU zimg upscale, which drops below real-time - # on the A72. Software decode of 1080p H.264 fits 4 cores fine. + # 4-thread software decode tuning for the Pi4-64 A72 / Pi5 A76: + # mpv's default --vd-lavc-threads=0 (auto) picks min(cores+1, 16) + # but caps under-utilization on small cores. Pinning to 4 fits + # 1080p H.264 with measurable headroom on both SoCs. + # --drm-mode=1920x1080@60 was previously paired with this on the + # --vo=drm path to dodge A72 CPU zimg upscale at 4K; under + # --vo=gpu --gpu-context=wayland the GPU does the scaling, so + # mpv can render at the connector's native mode and the + # --drm-mode pin is a no-op (cage holds DRM master). device_type = os.environ.get('DEVICE_TYPE', '') extra_args: list[str] = [] if device_type in ('pi4-64', 'pi5'): - extra_args = [ - '--drm-mode=1920x1080@60', - '--vd-lavc-threads=4', - ] - - # x86 runs under `cage` (a wlroots kiosk compositor — see - # bin/start_viewer.sh); cage holds DRM master, so --vo=drm is - # denied. Route mpv through the GL VO over a Wayland EGL - # context, which is the generic path mpv supports on every x86 - # GPU with Mesa or vendor GL drivers. Paired with - # --hwdec=auto-safe, VAAPI-capable iGPUs (Intel iHD/i965, AMD - # radeonsi, …) decode in hardware and hand frames to the GL - # context as DMA-BUFs via dmabuf-interop-gl; software decode - # still works via the same VO for codecs without HW support. - # --vo=dmabuf-wayland would skip the GL upload entirely but - # segfaults under cage in the viewer's background-spawn path - # (mpv 0.40.0 + wlroots-0.18 + libplacebo dies between hwdec - # init and file open). Pi boards (pi4-64/pi5) keep --vo=drm — - # they own the framebuffer directly with no compositor. - # arm64 (non-Pi ARM SBCs on Armbian) goes the same - # route as x86 because the viewer container is wrapped in - # cage there too; hwdec is best-effort per SoC. - if device_type in ('x86', 'arm64'): - vo_args = ['--vo=gpu', '--gpu-context=wayland'] - else: - vo_args = ['--vo=drm'] + extra_args = ['--vd-lavc-threads=4'] + + # All Qt6 boards run under `cage` (a wlroots kiosk compositor — + # see bin/start_viewer.sh); cage holds DRM master, so --vo=drm + # is denied. Route mpv through the GL VO over a Wayland EGL + # context, the generic path mpv supports on every x86 / Pi / + # Mesa-on-arm64 GPU. Paired with --hwdec=auto-safe, hardware + # decoders hand frames to the GL context as DMA-BUFs via + # dmabuf-interop-gl: VAAPI on x86 (Intel iHD/i965, AMD + # radeonsi, …), V4L2-request / V4L2-M2M on Pi4-64 (vc4) and + # Pi5 (hantro), and per-SoC best-effort on arm64 (rkvdec / + # cedrus / meson-vdec). Software decode still works via the + # same VO for codecs without HW support. --vo=dmabuf-wayland + # would skip the GL upload entirely but segfaults under cage + # in the viewer's background-spawn path (mpv 0.40.0 + + # wlroots-0.18 + libplacebo dies between hwdec init and file + # open). Qt5 boards (pi2/pi3) are routed through VLC by + # MediaPlayerProxy and never reach this code path. + vo_args = ['--vo=gpu', '--gpu-context=wayland'] self.process = subprocess.Popen( [ diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 70e4913f2..7b25fd702 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -51,15 +51,17 @@ def test_play_invokes_popen_with_expected_args( mock_popen: Any, _mock_detect: Any, mpv: _MPVFixtures ) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4'}): + with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): mpv.player.play() mock_popen.assert_called_once_with( [ 'mpv', '--no-terminal', - '--vo=drm', + '--vo=gpu', + '--gpu-context=wayland', '--hwdec=auto-safe', + '--vd-lavc-threads=4', '--audio-device=alsa/sysdefault:CARD=vc4hdmi0', '--', 'file:///test/video.mp4', @@ -70,7 +72,7 @@ def test_play_invokes_popen_with_expected_args( @patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_pins_1080p_mode_on_pi4_64( +def test_play_tunes_decoder_threads_on_pi4_64( mock_popen: Any, mpv: _MPVFixtures ) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) @@ -78,14 +80,15 @@ def test_play_pins_1080p_mode_on_pi4_64( mpv.player.play() args, _ = mock_popen.call_args - assert '--drm-mode=1920x1080@60' in args[0] assert '--vd-lavc-threads=4' in args[0] assert '--hwdec=auto-safe' in args[0] - assert '--hwdec=v4l2m2m-copy' not in args[0] + # --drm-mode pinning is gone: under cage+Wayland the GPU does the + # scaling, so the A72 no longer runs CPU zimg upscale at 4K. + assert '--drm-mode=1920x1080@60' not in args[0] @patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_pins_1080p_mode_on_pi5( +def test_play_tunes_decoder_threads_on_pi5( mock_popen: Any, mpv: _MPVFixtures ) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) @@ -93,12 +96,12 @@ def test_play_pins_1080p_mode_on_pi5( mpv.player.play() args, _ = mock_popen.call_args - assert '--drm-mode=1920x1080@60' in args[0] assert '--vd-lavc-threads=4' in args[0] + assert '--drm-mode=1920x1080@60' not in args[0] @patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_does_not_pin_mode_on_x86( +def test_play_omits_pi_tuning_on_x86( mock_popen: Any, mpv: _MPVFixtures ) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) @@ -110,29 +113,16 @@ def test_play_does_not_pin_mode_on_x86( assert '--vd-lavc-threads=4' not in args[0] +@pytest.mark.parametrize('device_type', ['x86', 'arm64', 'pi4-64', 'pi5']) @patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_uses_wayland_vo_on_x86( - mock_popen: Any, mpv: _MPVFixtures -) -> None: - mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'x86'}): - mpv.player.play() - - args, _ = mock_popen.call_args - assert '--vo=gpu' in args[0] - assert '--gpu-context=wayland' in args[0] - assert '--vo=drm' not in args[0] - - -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_uses_wayland_vo_on_arm64( - mock_popen: Any, mpv: _MPVFixtures +def test_play_uses_wayland_vo_on_all_qt6_boards( + mock_popen: Any, mpv: _MPVFixtures, device_type: str ) -> None: - # arm64 runs under cage (same as x86), so mpv must go - # through --vo=gpu --gpu-context=wayland — cage holds DRM master - # and would deny --vo=drm. + # All Qt6 boards run under cage (a wlroots kiosk compositor); cage + # holds DRM master, so --vo=drm would be denied. Every Qt6 board + # must route through --vo=gpu --gpu-context=wayland instead. mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}): + with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): mpv.player.play() args, _ = mock_popen.call_args diff --git a/tools/image_builder/utils.py b/tools/image_builder/utils.py index ae1b36128..bf731f04a 100644 --- a/tools/image_builder/utils.py +++ b/tools/image_builder/utils.py @@ -181,14 +181,14 @@ def get_viewer_context(board: str, target_platform: str) -> dict[str, Any]: # from apt at runtime — except on Qt 6 boards where qt6-*-dev # below also provides the runtime libs.) # - # X11/XCB packages are intentionally absent: the WebView is - # configured with `-no-xcb -no-xcb-xlib -qpa eglfs` (see - # webview/build_qt5.sh) and runs under QT_QPA_PLATFORM=linuxfb - # straight on KMS/DRM, so Qt has no X code path to dlopen. mpv - # uses --vo=drm. Wayland is similarly absent on Pi for the same - # reason; the x86 board is the one exception (it has no /dev/fb0, - # so the qt6-wayland + cage pair is added to the per-board apt - # extension below). + # X11/XCB packages are intentionally absent. Qt5 boards (pi2/pi3) + # use a custom -no-xcb -no-xcb-xlib -qpa eglfs build of the WebView + # (see webview/build_qt5.sh) and run under QT_QPA_PLATFORM=linuxfb + # straight on KMS/DRM, with mpv on --vo=drm. Qt6 boards (pi4-64, + # pi5, x86, arm64) run the viewer under `cage` (a kiosk wlroots + # compositor) with QT_QPA_PLATFORM=wayland and mpv on --vo=gpu + # --gpu-context=wayland — no X code path on either track. The + # cage + qt6-wayland pair is added to the Qt6 apt extension below. viewer_extra_apt_dependencies = [ 'ca-certificates', 'dbus-daemon', @@ -252,36 +252,26 @@ def get_viewer_context(board: str, target_platform: str) -> dict[str, Any]: ] if is_qt6: - # pi4-64/pi5 use mpv --vo=drm; x86 uses mpv --vo=gpu - # --gpu-context=wayland under cage with VAAPI hwdec (see - # MPVMediaPlayer.play in src/anthias_viewer/media_player.py). - # VLC is deliberately *not* installed: MediaPlayerProxy routes - # Qt6 boards to MPVMediaPlayer, so VLC would just be ~80–100 MB - # of dead weight here. + # All Qt6 boards (pi4-64, pi5, x86, arm64) run the viewer + # under `cage`, a wlroots kiosk compositor, with mpv on + # --vo=gpu --gpu-context=wayland for video. See + # MPVMediaPlayer.play in src/anthias_viewer/media_player.py + # and bin/start_viewer.sh for the runtime wiring. VLC is + # deliberately *not* installed: MediaPlayerProxy routes Qt6 + # boards to MPVMediaPlayer, so VLC would just be ~80–100 MB + # of dead weight here. qt6-wayland is the Qt platform plugin + # the viewer loads to render into cage's surface. viewer_extra_apt_dependencies.extend( [ + 'cage', 'mpv', 'qt6-base-dev', + 'qt6-wayland', 'qt6-webengine-dev', 'qt6-image-formats-plugins', ] ) - if board in ('x86', 'arm64'): - # balenaOS x86 has no /dev/fb0 for Qt's linuxfb plugin and - # no host display server. cage is a kiosk wlroots - # compositor that talks straight to KMS; qt6-wayland is - # the Qt platform plugin the viewer loads to render into - # cage's surface. The same wiring fits arm64 - # (non-Pi 64-bit ARM SBCs running Armbian): no /dev/fb0 - # by default, so cage is the portable kiosk path. - viewer_extra_apt_dependencies.extend( - [ - 'cage', - 'qt6-wayland', - ] - ) - if board == 'x86': # va-driver-all is a Debian metapackage that pulls in # intel-media-va-driver (modern Intel iHD), i965-va-driver diff --git a/website/data/faq.yaml b/website/data/faq.yaml index e15ce7b3b..06f9eb376 100644 --- a/website/data/faq.yaml +++ b/website/data/faq.yaml @@ -73,7 +73,7 @@ items: - question: How do I rotate the screen for portrait orientation? answer: | - Rotation happens at the kernel / firmware level. The Anthias viewer renders straight to the Linux framebuffer (Qt `linuxfb`) and to KMS (mpv `--vo=drm`) — there's no Wayland compositor in the stack — so the standard Raspberry Pi config knobs apply directly. + Rotation happens at the kernel / firmware level on the Pi. The viewer renders through `cage` (a kiosk Wayland compositor that talks straight to KMS) on Pi4/Pi5/x86/arm64, and through Qt `linuxfb` directly on legacy 32-bit Pi boards (Pi2/Pi3) — neither path goes through a desktop compositor, so the standard Raspberry Pi config knobs apply directly. Edit `/boot/firmware/config.txt` and add one of: @@ -98,7 +98,7 @@ 1. SSH in and run `docker compose ps` from `~/anthias/` — all four containers (`anthias-server`, `anthias-celery`, `anthias-viewer`, `redis`) should be `Up`. 2. If a container is restarting, tail its logs: `docker logs -f anthias-anthias-viewer-1` (or `…-server-1`). 3. Open the dashboard from another device and confirm at least one asset is **enabled** with a current schedule. - 4. If the display has gone to power save, briefly unplug and reconnect the HDMI cable, or check the TV's input source. Anthias renders to the framebuffer (Qt `linuxfb`) and uses no X server, so `xset` won't help. + 4. If the display has gone to power save, briefly unplug and reconnect the HDMI cable, or check the TV's input source. Anthias renders directly to KMS (via `cage` on Pi4/Pi5/x86/arm64, or Qt `linuxfb` on Pi2/Pi3) and uses no X server, so `xset` won't help. If a recent update broke the display, running `~/anthias/bin/upgrade_containers.sh` re-creates the containers cleanly.