diff --git a/bin/test_webview_cpp.sh b/bin/test_webview_cpp.sh new file mode 100755 index 000000000..68ef0410e --- /dev/null +++ b/bin/test_webview_cpp.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build and run AnthiasWebview's QtTest unit tests. +# +# Requires Qt 6 (qt6-base-dev, qt6-base-private-dev) and libmpv-dev +# on the host or in a builder container. The viewer Docker image +# already ships those for the per-board builder stage — running +# this inside that container is the canonical environment. +# +# Usage: bin/test_webview_cpp.sh +# +# The tests run against libmpv WITHOUT a real OpenGL context; +# mpv_render_context_create is exercised by real-device validation +# on the BBB test bed instead. CI integration is a follow-up. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +WEBVIEW_DIR="src/anthias_webview" +BUILD_DIR="${WEBVIEW_DIR}/tests/build" + +mkdir -p "${BUILD_DIR}" +pushd "${BUILD_DIR}" >/dev/null + +qmake6 ../tests.pro +make -j"$(nproc)" + +# QTEST_MAIN's generated binary exits non-zero on any test failure. +# ``QT_QPA_PLATFORM=offscreen`` skips connecting to a real display +# server / framebuffer — the tests don't render, they just exercise +# the mpv_handle property contract. +QT_QPA_PLATFORM=offscreen ./AnthiasWebviewTests + +popd >/dev/null diff --git a/docker/Dockerfile.viewer.j2 b/docker/Dockerfile.viewer.j2 index cbe92caf3..312626a12 100644 --- a/docker/Dockerfile.viewer.j2 +++ b/docker/Dockerfile.viewer.j2 @@ -18,12 +18,18 @@ RUN --mount=type=cache,target=/var/cache/apt,id=qt6-builder-apt-{{ artifact_boar apt-get install -y --no-install-recommends \ build-essential \ qt6-base-dev \ + qt6-multimedia-dev \ qt6-webengine-dev +# qt6-multimedia-dev: AnthiasWebview's VideoView wraps QMediaPlayer + +# QVideoWidget (issue #2904, see ``src/anthias_webview/AnthiasWebview.pro``). +# Runtime backend is gstreamer (forced via ``QT_MEDIA_BACKEND`` env +# below); plugin discovery happens at runtime against the +# ``gstreamer1.0-*`` packages added to ``viewer_extra_apt_dependencies``. COPY src/anthias_webview/AnthiasWebview.pro /src/anthias_webview/AnthiasWebview.pro COPY src/anthias_webview/src /src/anthias_webview/src COPY src/anthias_webview/res /src/anthias_webview/res WORKDIR /src/anthias_webview -# webview is 3 .cpp files (~6s on x86 native, ~30s under arm64 QEMU); +# webview is 4 .cpp files (~6s on x86 native, ~30s under arm64 QEMU); # adding ccache would only save a couple of seconds on incremental # rebuilds and the cache mount + apt install + PATH wiring would # outweigh the saving. @@ -76,10 +82,35 @@ ENV QT_QPA_EGLFS_FORCE888=1 # the viewer in cage; this env var tells Qt to load the qt6-wayland # platform plugin so it renders into cage's surface. ENV QT_QPA_PLATFORM=wayland +{% elif board == 'pi4-64' %} +# Pi 4 switched from ``linuxfb`` to ``eglfs`` for issue #2904: +# libmpv-render needs an OpenGL context to paint frames into the +# AnthiasWebview window, and ``linuxfb`` has none. ``eglfs`` (the +# Qt KMS/EGL platform) gives Qt a GL context paired with KMS +# scanout. ``QT_QPA_EGLFS_KMS_CONFIG`` pins 1080p — the V3D 6.0 +# can't composite Chromium + libmpv on top of the connector's +# native 4K, and Qt's ``eglfs_kms`` integration reads this JSON +# at QPA init time. Validated on real Pi 4 hardware: under eglfs +# QWebEngineProcess still spawns its zygote + 2 renderers and +# loads pages, so the platform switch doesn't regress web / +# image rendering. +ENV QT_QPA_PLATFORM=eglfs +ENV QT_QPA_EGLFS_KMS_CONFIG=/etc/anthias/eglfs-kms.json +# bin/start_viewer.sh auto-detects QT_SCALE_FACTOR from +# /sys/class/drm/*-*/modes — but the kernel mode list still +# reports the connector's EDID-preferred mode (4K on most TVs), +# even though eglfs is rendering into a 1080p framebuffer per the +# KMS config above. Pinning scale=1 short-circuits the auto-detect +# (which respects an existing value) so a 1920-wide page lays out +# at 1920 physical px on the 1080p surface — i.e. CSS px ≡ physical +# px, the same end-state the linuxfb+4K-with-scale=2 path produced. +ENV QT_SCALE_FACTOR=1 +RUN mkdir -p /etc/anthias +COPY docker/eglfs-kms-pi4.json /etc/anthias/eglfs-kms.json {% else %} -# Pi 4 (Qt6) and Pi 2 / Pi 3 (Qt5) keep the classic linuxfb path — -# the Pi 4 V3D 6.0 can't keep up with cage's composite pass on top -# of video, so the viewer renders directly to the framebuffer. +# Pi 2 / Pi 3 (Qt5) keep the classic linuxfb path — VLC paints +# directly to the framebuffer and there's no libmpv embedding on +# those boards. ENV QT_QPA_PLATFORM=linuxfb {% endif %} diff --git a/docker/eglfs-kms-pi4.json b/docker/eglfs-kms-pi4.json new file mode 100644 index 000000000..0c0e20660 --- /dev/null +++ b/docker/eglfs-kms-pi4.json @@ -0,0 +1,15 @@ +{ + "device": "/dev/dri/card1", + "hwcursor": false, + "pbuffers": true, + "outputs": [ + { + "name": "HDMI1", + "mode": "1920x1080" + }, + { + "name": "HDMI2", + "mode": "1920x1080" + } + ] +} diff --git a/src/anthias_viewer/__init__.py b/src/anthias_viewer/__init__.py index 8066aaca7..b0a6d9b4e 100644 --- a/src/anthias_viewer/__init__.py +++ b/src/anthias_viewer/__init__.py @@ -20,6 +20,7 @@ from anthias_viewer.constants import SPLASH_DELAY as SPLASH_DELAY from anthias_viewer.constants import SPLASH_PAGE_URL as SPLASH_PAGE_URL from anthias_viewer.constants import STANDBY_SCREEN as STANDBY_SCREEN +from anthias_viewer import media_player as _media_player_module from anthias_viewer.media_player import MediaPlayerProxy from anthias_viewer.playback import ( navigate_to_asset, @@ -907,6 +908,12 @@ def setup() -> None: bus = pydbus.SessionBus() browser_bus = bus.get('anthias.webview', '/Anthias') + # MPVMediaPlayer calls AnthiasWebview's playVideo / stopVideo + # slots via this same proxy now that video lives in-process + # (issue #2904). Inject after load_browser so the proxy is + # already bound to the running AnthiasWebview; load_browser + # would otherwise race the D-Bus name registration. + _media_player_module.set_browser_bus(browser_bus) def start_loop() -> None: diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index c82002277..09caac9e0 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -1,9 +1,8 @@ import logging import os -import subprocess -from typing import IO, ClassVar +from typing import Any, ClassVar -from anthias_common.board import ARM64_DEVICE_TYPES, resolve_device_key +from anthias_common.board import ARM64_DEVICE_TYPES from anthias_common.device_helper import get_device_type from anthias_common.utils import clamp_screen_rotation from anthias_server.settings import settings @@ -11,6 +10,32 @@ VIDEO_TIMEOUT = 20 # secs +# Lazy import for the pydbus proxy: the viewer service hands +# MPVMediaPlayer the same ``browser_bus`` object it uses for +# loadPage / loadImage (created in src/anthias_viewer/__init__.py +# during load_browser()). Tests inject a mock; importing pydbus at +# module load time would force every test to have pydbus available +# even when only exercising VLCMediaPlayer. +_browser_bus: Any = None + + +def set_browser_bus(bus: Any) -> None: + """Inject the AnthiasWebview D-Bus proxy. + + Called from ``anthias_viewer/__init__.py`` after the webview's + ``Anthias service start`` handshake. ``MPVMediaPlayer`` reads + this module-global on every play() / stop() so a webview crash + + re-launch (with a fresh ``browser_bus`` proxy) can re-inject + without rebuilding the media player. + """ + global _browser_bus + _browser_bus = bus + + +def get_browser_bus() -> Any: + return _browser_bus + + def _screen_rotation() -> int: """Cardinal angle the operator selected on the Settings page. @@ -208,280 +233,118 @@ def is_playing(self) -> bool: raise NotImplementedError -# Per-codec hwdec preference on Pi, per-board: -# -# Pi 4: H.264 → v4l2m2m-copy (V3D V4L2 M2M decoder via -# bcm2835-codec, up to 1080p60) -# HEVC → drm-copy (FFmpeg v4l2_request_hevc, up to -# 4Kp60 via the dedicated HEVC block) -# -# Pi 5: H.264 → auto-copy (Hantro G1 silicon exists but mpv -# has no v4l2-request H.264 hwdec -# upstream; passing v4l2m2m-copy -# here would just log "Could not -# find a valid device" errors before -# silently SW-falling-back. The -# playback envelope (HEVC 4Kp60) means -# every asset normalised post-rollout -# lands as HEVC, so this branch only -# fires for legacy variants the -# re-render walker hasn't caught yet.) -# HEVC → drm-copy (Hantro G2, up to 4Kp60. Requires -# `dtoverlay=vc4-kms-v3d,cma-512` in -# /boot/firmware/config.txt — the -# stock 64 MB CMA region can't fit -# a 4K HEVC dst buffer pool, and the -# kernel cmdline `cma=` route silently -# orphans the rpi-hevc-dec driver.) -# -# generic-arm64 (Armbian SBCs — Rock Pi 4, Orange Pi, etc.): -# H.264 → v4l2m2m-copy (RK3399 Hantro via the v4l2m2m -# HEVC → v4l2m2m-copy driver. mpv's --hwdec=help on the -# latest-generic-arm64 image lists -# both h264_v4l2m2m and hevc_v4l2m2m; -# on boards without a working driver -# mpv logs a warning and SW-falls- -# back at runtime.) -# -# `auto-copy` is the universal safe fallback when ffprobe can't -# read the codec (missing file, network URI we don't probe, etc.). -# -# An earlier revision did this with a Lua on_load hook, but -# video-codec-name is empty at every event mpv exposes to scripts -# before hwdec init (on_load, on_preloaded). ffprobing from Python -# at launch time is both simpler and the only thing that actually -# works. -_PI_HWDEC_BY_CODEC: dict[str, dict[str, str]] = { - 'pi4-64': {'h264': 'v4l2m2m-copy', 'hevc': 'drm-copy'}, - 'pi5': {'hevc': 'drm-copy'}, - # Rock Pi 4 (RK3399, Radxa). Both codecs go through - # ``drm-copy``: the arm64 viewer image now pulls the Raspberry - # Pi repo's ffmpeg (``+rpt1``, with ``--enable-v4l2-request`` - # — same package family as Pi 4 / Pi 5), so mpv exposes the - # stateless v4l2_request decoders that the RK3399's ``rkvdec`` - # (HEVC) and ``rockchip,rk3399-vpu-dec`` Hantro VPU (H.264) - # implement. The ``start_viewer.sh`` entrypoint creates the - # ``/dev/video-dec*`` symlinks the v4l2_request decoder - # discovery code expects (privileged docker mounts its own - # /dev tmpfs without udev's symlinks). - 'rockpi4': {'h264': 'drm-copy', 'hevc': 'drm-copy'}, -} - - -def _probe_video_codec(uri: str) -> str: - """Return the canonical lowercase video codec name for ``uri``. - - Empty string on probe failure (missing file, unreadable codec, - ffprobe absent, etc.) — callers should then pick a safe - fallback like ``auto-copy``. Short timeout because this runs - synchronously before every mpv launch. +def _marshal_dbus_options(options: dict[str, str]) -> dict: + """Wrap each value as a ``GLib.Variant('s', ...)`` for pydbus. + + AnthiasWebview's ``playVideo`` slot is declared with + ``QVariantMap`` (Qt's ``a{sv}`` D-Bus signature) so the dict + values are *variant-typed* across the wire. pydbus refuses to + auto-coerce a plain Python ``str`` to ``GLib.Variant`` ("Expected + GLib.Variant, but got str") — the wrap has to happen on the + Python side. Tests monkeypatch this function to the identity so + they can assert on the plain str dict. """ - try: - result = subprocess.run( - [ - 'ffprobe', - '-v', - 'error', - '-select_streams', - 'v:0', - '-show_entries', - 'stream=codec_name', - '-of', - 'default=nw=1:nk=1', - uri, - ], - capture_output=True, - text=True, - timeout=5, - ) - return result.stdout.strip().lower() - except (subprocess.SubprocessError, OSError): - return '' + from gi.repository import GLib + + return {key: GLib.Variant('s', value) for key, value in options.items()} + +def _build_mpv_options(uri: str) -> dict[str, str]: + """Build the per-file option dict sent over D-Bus to QtMultimedia. -def _pi_hwdec_for_uri(uri: str) -> str: - """mpv ``--hwdec=`` value for ``uri`` on Pi 4 / Pi 5 / Rock Pi 4. + QtMultimedia + GStreamer auto-picks the best decoder element + via ``decodebin3`` (``v4l2slh264dec`` / ``v4l2slh265dec`` on + Pi 4 / Pi 5 / Rock Pi 4 via the rpt3 ``gstreamer1.0-plugins-bad`` + pin in docker/_rpt1-ffmpeg-pin.j2, ``vaapi`` on x86). The + application no longer dispatches per-codec hwdec; the + options dict shrinks to: - Reads the board key via ``resolve_device_key``, which upgrades a - catch-all ``arm64`` DEVICE_TYPE to the specific subtype the - host_agent publishes (Rock Pi 4 → ``rockpi4``). ``auto-copy`` is - the safe fallback when ffprobe can't read the codec; the upload - gate (`anthias_server.processing._hw_decoded_codecs`) prevents - any codec outside ``_PI_HWDEC_BY_CODEC`` reaching this branch in - the first place. + * ``audio-device`` — ALSA device name (the same string mpv + consumed; gstreamer's ALSA sink and Qt's ``QAudioDevice`` + resolve it identically). + * ``video-rotate`` — Pi 4 only. Cage / wayland boards already + get the transform from wlr-randr at the compositor level; + sending ``video-rotate`` on top would double-rotate. + + The ``uri`` argument is kept on the signature for symmetry + with the libmpv era (where it fed ffprobe). It's no longer + read because gstreamer handles codec probing internally — + but a future codec-specific tuning may re-introduce it. """ - board_map = _PI_HWDEC_BY_CODEC.get(resolve_device_key(), {}) - return board_map.get(_probe_video_codec(uri), 'auto-copy') + del uri # see docstring; kept for signature compatibility. + device_type = os.environ.get('DEVICE_TYPE', '') + + options: dict[str, str] = { + 'audio-device': f'alsa/{get_alsa_audio_device()}', + } + + # Rotation: cage/wlroots boards rotate via wlr-randr (issue + # #2856, wired in src/anthias_viewer/__init__.py) and Qt's + # wayland QPA inherits the transform — passing video-rotate + # on top would double-rotate. On Pi 4 (eglfs, no compositor) + # Qt has no transform plumbing, so the video pipeline has to + # apply the rotation itself. + rotation = _screen_rotation() + if rotation and device_type == 'pi4-64': + options['video-rotate'] = str(rotation) + + return options class MPVMediaPlayer(MediaPlayer): def __init__(self) -> None: MediaPlayer.__init__(self) - self.process: subprocess.Popen[bytes] | None = None self.uri: str = '' + # No mpv subprocess any more — the C++ AnthiasWebview owns + # the libmpv handle. Track local playback state so + # is_playing() (called only by tests today; the asset_loop + # sleeps for ``duration``) can still answer without a D-Bus + # round-trip. + self._playing: bool = False def set_asset(self, uri: str, duration: int | str) -> None: self.uri = uri def play(self) -> None: - # Re-read settings each play so the audio_output dropdown takes - # effect without a viewer restart, matching VLCMediaPlayer. + # Re-read settings each play so the audio_output dropdown + # takes effect without a viewer restart, matching the prior + # subprocess path and 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. - # Pi 5 keeps the same tuning on the cage path — it doesn't - # hurt, and mpv ignores --drm-mode under --vo=gpu - # --gpu-context=wayland anyway. - device_type = os.environ.get('DEVICE_TYPE', '') - extra_args: list[str] = [] - if device_type == 'pi4-64': - extra_args = [ - '--drm-mode=1920x1080@60', - '--vd-lavc-threads=4', - ] - elif device_type == 'pi5': - extra_args = ['--vd-lavc-threads=4'] - - # Per-board VO selection: - # - # * x86 / arm64 / pi5 run under `cage` (a wlroots kiosk - # compositor — see bin/start_viewer.sh); cage holds DRM - # master, so --vo=drm is denied. mpv goes through the GL - # VO over a Wayland EGL context. Paired with - # --hwdec=auto-safe, VAAPI-capable iGPUs on x86 (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. Pi 5's V3D 7.1 has enough bandwidth to composite - # at the connector's native mode (typically 4K) on top of - # software-decoded video. arm64 is best-effort per SoC. - # --vo=dmabuf-wayland would skip the GL upload entirely - # but segfaults under cage (mpv 0.40 + wlroots-0.18 + - # libplacebo dies between hwdec init and file open). - # - # * Pi4-64 stays on Qt linuxfb (no compositor) with mpv's - # --vo=drm. The V3D 6.0 doesn't have the bandwidth to - # composite cage on top of software-decoded video at 4K - # (738 vo drops/30 s in testing). mpv's --vo=drm does its - # own DRM master juggling — briefly grabbing master, - # rendering, dropping back — which coexists with Qt - # linuxfb in a way that --vo=gpu --gpu-context=drm does - # not (Mesa GBM holds master persistently and contends - # with Qt's framebuffer use, manifesting as "Failed to - # acquire DRM master: Permission denied"). So Pi 4 stays - # on --vo=drm + --drm-mode=1920x1080@60 — the production - # path inherited from master. - # ``generic-arm64`` is the legacy label that pre-rename arm64 - # images carry — it shares the cage + Wayland stack with - # ``arm64`` and must take the same VO path. Without this the - # Rock Pi 4 (still on a generic-arm64 image) falls into the - # ``--vo=drm`` else branch and mpv aborts with "No primary - # DRM device could be picked" because cage holds DRM master. - if device_type in ('x86', 'arm64', 'generic-arm64', 'pi5'): - vo_args = ['--vo=gpu', '--gpu-context=wayland'] - else: - vo_args = ['--vo=drm'] - - # Rotation: cage/wlroots is rotated via wlr-randr (issue - # #2856, the wiring lives in src/anthias_viewer/__init__.py) - # and mpv's wayland VO inherits the compositor transform - # automatically — passing --video-rotate would double-rotate - # there. On Pi 4 (linuxfb, no compositor) mpv has to apply - # the transform itself. - rotation = _screen_rotation() - rotate_args: list[str] = [] - if rotation and device_type == 'pi4-64': - rotate_args = [f'--video-rotate={rotation}'] - - # Hwdec selection. Strategy summary: - # - # * x86 / arm64 → `--hwdec=auto-copy`. Picks vaapi-copy on - # Intel/AMD iGPUs (the only HW decode method mpv 0.40 ships - # with for x86 outside of NVIDIA-specific options); on - # arm64 there's nothing in auto-copy that matches - # Rockchip's V4L2 stateless decoder, so it falls back to - # software. arm64 HW decode via a vendor-tuned plugin is a - # Tier-2 follow-up. - # * Pi 4 / Pi 5 → ffprobe the asset (~50 ms for a local - # file) and pick per-codec, because `auto-copy`'s upstream - # whitelist deliberately excludes v4l2m2m-copy (H.264 V3D - # M2M is the path Pi 4 needs). See _pi_hwdec_for_uri(). - # ffprobe-and-dispatch on any board the per-codec map covers - # (Pi 4 / Pi 5 / Rock Pi 4 via host_agent subtype). x86, the - # arm64 catch-all without a subtype, and Pi 2 / Pi 3 fall - # through to ``auto-copy`` and don't pay the ffprobe cost. - if resolve_device_key() in _PI_HWDEC_BY_CODEC: - hwdec_value = _pi_hwdec_for_uri(self.uri) - else: - hwdec_value = 'auto-copy' - - # ANTHIAS_DEBUG_DROPS=1: when set on the viewer container, - # mpv's stdout/stderr go to a host-bound log instead of - # /dev/null, *and* --no-terminal is dropped so mpv's normal - # status line ("AV: 00:00:30 / ... Dropped: N") is emitted. - # The log records hwdec-current / VO init banners plus - # per-file drop counts so reviewers can validate the test - # bed without rebuilding the image. Default (unset) - # preserves the silent stdout/stderr=/dev/null behaviour. - debug_drops = os.environ.get('ANTHIAS_DEBUG_DROPS') == '1' - terminal_args = [] if debug_drops else ['--no-terminal'] - # Popen accepts either an int sentinel (DEVNULL / STDOUT) or - # an already-opened binary IO stream for stdout/stderr. The - # int | IO[bytes] union covers both. - popen_stdout: int | IO[bytes] - popen_stderr: int | IO[bytes] - if debug_drops: - log_fd = open('/data/.anthias/mpv.log', 'ab', buffering=0) - log_fd.write(f'\n--- mpv launch {self.uri} ---\n'.encode()) - popen_stdout = log_fd - popen_stderr = subprocess.STDOUT - else: - popen_stdout = subprocess.DEVNULL - popen_stderr = subprocess.DEVNULL - - # ``--video-sync=display-resample`` overrides mpv 0.40's - # default (``audio``) which syncs video to the audio clock - # and drops VO frames when the two clocks drift. On every - # board we tested (Pi 4, Pi 5, x86) the audio-clock default - # produced 60–90% VO drops at 60 fps content even when the - # decoder was healthy (mpv reports drops at the VO, not the - # decoder). Digital signage cares about smooth video more - # than sub-frame A/V sync; display-resample syncs video to - # the display's refresh and resamples audio to match. Audio - # resampling is cheap (a 2-channel resample takes <1% CPU) - # and most signage clips have no audible content anyway. - self.process = subprocess.Popen( - [ - 'mpv', - *terminal_args, - *vo_args, - f'--hwdec={hwdec_value}', - '--video-sync=display-resample', - *extra_args, - *rotate_args, - f'--audio-device=alsa/{get_alsa_audio_device()}', - '--', - self.uri, - ], - stdout=popen_stdout, - stderr=popen_stderr, - ) + options = _build_mpv_options(self.uri) + + bus = get_browser_bus() + if bus is None: + logging.error( + 'MPVMediaPlayer.play: AnthiasWebview D-Bus proxy not ' + 'set — call set_browser_bus() after the webview ' + 'handshake (src/anthias_viewer/__init__.py).' + ) + return + + try: + bus.playVideo(self.uri, _marshal_dbus_options(options)) + self._playing = True + except Exception as exc: + # pydbus surfaces transport / signature errors as + # generic exceptions. Log + clear local state so a + # transient AnthiasWebview crash doesn't leave the + # player thinking a video is on screen. + logging.error('MPVMediaPlayer.play failed: %s', exc) + self._playing = False def stop(self) -> None: + self._playing = False + bus = get_browser_bus() + if bus is None: + return try: - if self.process: - self.process.terminate() - self.process = None - except Exception as e: - logging.error(f'Exception in stop(): {e}') + bus.stopVideo() + except Exception as exc: + logging.error('MPVMediaPlayer.stop failed: %s', exc) def is_playing(self) -> bool: - if self.process: - return self.process.poll() is None - return False + return self._playing class VLCMediaPlayer(MediaPlayer): diff --git a/src/anthias_webview/AnthiasWebview.pro b/src/anthias_webview/AnthiasWebview.pro index bdb453c52..ee9ca2cc6 100644 --- a/src/anthias_webview/AnthiasWebview.pro +++ b/src/anthias_webview/AnthiasWebview.pro @@ -1,10 +1,23 @@ TEMPLATE = app -QT += webenginecore webenginewidgets dbus +QT += webenginecore webenginewidgets dbus multimedia multimediawidgets CONFIG += c++17 +# QtMultimedia is the in-process video pipeline (issue #2904). An +# earlier revision linked libmpv via ``mpv_render_context`` into a +# ``QOpenGLWidget``; that engaged HW decode correctly but Pi 4 V3D +# 6.0 couldn't sustain 60 fps through libmpv-render's GL upload + +# FBO + Qt compositor pipeline. QtMultimedia with the gstreamer +# backend (forced via ``QT_MEDIA_BACKEND=gstreamer`` in the runtime +# env) routes the rpi v4l2 stateless decoders (``v4l2slh264dec`` / +# ``v4l2slh265dec`` from rpt3 ``gstreamer1.0-plugins-bad``) directly +# to ``QVideoSink`` — fewer copies, no FBO indirection, and +# ``QVideoWidget`` paints inside MainWindow's single eglfs native +# window so we don't trip eglfs's single-window-per-process limit. + SOURCES += src/main.cpp \ src/mainwindow.cpp \ + src/videoview.cpp \ src/view.cpp # Default rules for deployment. @@ -12,4 +25,5 @@ include(src/deployment.pri) HEADERS += \ src/mainwindow.h \ + src/videoview.h \ src/view.h diff --git a/src/anthias_webview/src/main.cpp b/src/anthias_webview/src/main.cpp index 45211d789..0fc8c35cd 100644 --- a/src/anthias_webview/src/main.cpp +++ b/src/anthias_webview/src/main.cpp @@ -15,7 +15,16 @@ int main(int argc, char *argv[]) QDBusConnection connection = QDBusConnection::sessionBus(); - if (!connection.registerObject("/Anthias", window, QDBusConnection::ExportAllSlots)) + // ExportAllSlots covers loadPage / loadImage / setReloadInterval / + // playVideo / stopVideo; ExportAllSignals exposes MainWindow's + // ``videoEnded`` signal so the Python viewer can subscribe to it + // and learn when libmpv finishes a clip without polling (issue + // #2904 follow-up; the current asset_loop still sleeps for + // ``duration`` and doesn't subscribe). + if (!connection.registerObject( + "/Anthias", window, + QDBusConnection::ExportAllSlots + | QDBusConnection::ExportAllSignals)) { qWarning() << "Can't register object:" << connection.lastError().message(); return 1; diff --git a/src/anthias_webview/src/mainwindow.cpp b/src/anthias_webview/src/mainwindow.cpp index 4550a87d3..0f062d63e 100644 --- a/src/anthias_webview/src/mainwindow.cpp +++ b/src/anthias_webview/src/mainwindow.cpp @@ -9,6 +9,11 @@ MainWindow::MainWindow() : QMainWindow() { view = new View(this); setCentralWidget(view); + // Re-emit VideoView's EOF up to MainWindow so D-Bus + // ExportAllSignals exposes a single ``videoEnded`` signal on + // ``/Anthias`` (the same object path Python subscribes to for + // the existing slots). + connect(view, &View::videoEnded, this, &MainWindow::videoEnded); showFullScreen(); } @@ -27,3 +32,13 @@ void MainWindow::setReloadInterval(int seconds) { view->setReloadInterval(seconds); } + +void MainWindow::playVideo(const QString &uri, const QVariantMap &options) +{ + view->playVideo(uri, options); +} + +void MainWindow::stopVideo() +{ + view->stopVideo(); +} diff --git a/src/anthias_webview/src/mainwindow.h b/src/anthias_webview/src/mainwindow.h index 94ee236c1..ed10570aa 100644 --- a/src/anthias_webview/src/mainwindow.h +++ b/src/anthias_webview/src/mainwindow.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "view.h" @@ -16,6 +17,22 @@ class MainWindow : public QMainWindow void loadPage(const QString &uri); void loadImage(const QString &uri); void setReloadInterval(int seconds); + // libmpv-in-Qt video playback (issue #2904). Replaces the + // external mpv subprocess MPVMediaPlayer used to launch from + // src/anthias_viewer/media_player.py. ``options`` mirrors the + // mpv option set the subprocess path used to assemble as + // argv: ``hwdec``, ``audio-device``, ``video-sync``, + // ``vd-lavc-threads``, ``video-rotate``. Values are coerced + // to UTF-8 strings via QVariant::toString(). + void playVideo(const QString &uri, const QVariantMap &options); + void stopVideo(); + + signals: + // Re-emitted from VideoView::videoEnded — exported over + // D-Bus by main.cpp's QDBusConnection::ExportAllSignals so + // Python can subscribe in a future revision (the asset_loop + // currently just sleeps for ``duration``). + void videoEnded(); private: View *view = nullptr; diff --git a/src/anthias_webview/src/videoview.cpp b/src/anthias_webview/src/videoview.cpp new file mode 100644 index 000000000..4d796c1d1 --- /dev/null +++ b/src/anthias_webview/src/videoview.cpp @@ -0,0 +1,334 @@ +#include "videoview.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +VideoView::VideoView(QWidget* parent) : QWidget(parent) +{ + // Black background so the surface doesn't flash white at the + // start of playback while the gstreamer pipeline negotiates + // the first frame. + setAutoFillBackground(true); + QPalette pal = palette(); + pal.setColor(QPalette::Window, Qt::black); + setPalette(pal); + + videoLayout = new QHBoxLayout(this); + videoLayout->setContentsMargins(0, 0, 0, 0); + videoLayout->setSpacing(0); + + videoWidget = new QVideoWidget(this); + // ``KeepAspectRatio`` matches mpv's default; the BBB test bed + // assumes uniform scaling so a 16:9 clip on a 16:9 display + // fills the surface exactly. ``IgnoreAspectRatio`` would + // stretch to fill which we don't want. + videoWidget->setAspectRatioMode(Qt::KeepAspectRatio); + videoLayout->addWidget(videoWidget); + + player = new QMediaPlayer(this); + audioOutput = new QAudioOutput(this); + player->setAudioOutput(audioOutput); + player->setVideoOutput(videoWidget); + + connect(player, &QMediaPlayer::playbackStateChanged, + this, &VideoView::onPlaybackStateChanged); + connect(player, &QMediaPlayer::mediaStatusChanged, + this, &VideoView::onMediaStatusChanged); + connect(player, &QMediaPlayer::errorOccurred, + this, &VideoView::onErrorOccurred); + + // Count frames delivered to the sink so SAMPLE / END_FILE lines + // can report a "frames delivered" → "frames expected" delta. + // QVideoSink::videoFrameChanged fires once per displayed frame + // (after gstreamer drops happen upstream), which is exactly the + // metric we care about — frames that reached the display. + if (videoWidget->videoSink()) { + connect(videoWidget->videoSink(), &QVideoSink::videoFrameChanged, + this, &VideoView::onVideoFrameDelivered); + } + + // Stats log: same path as the libmpv era so the BBB test bed's + // ``docker exec cat /data/.anthias/mpv-stats.log`` workflow + // keeps working. Schema slightly different (no FILE_LOADED + // hwdec-current line; the LOADFILE / SAMPLE / END_FILE lines + // remain). + QDir().mkpath(QStringLiteral("/data/.anthias")); + statsFile = new QFile( + QStringLiteral("/data/.anthias/mpv-stats.log"), this); + if (statsFile->open(QIODevice::WriteOnly | QIODevice::Append + | QIODevice::Text)) { + statsStream = new QTextStream(statsFile); + writeStats( + QStringLiteral("INIT"), + QStringLiteral( + "backend=qtmultimedia/gstreamer qt=%1 " + "audio_default=%2") + .arg(QStringLiteral(QT_VERSION_STR), + QMediaDevices::defaultAudioOutput().description())); + } else { + qWarning() << "VideoView: could not open" + << statsFile->fileName() + << "for stats — playback will run without" + << "frame-drop logging."; + delete statsFile; + statsFile = nullptr; + } + + statsTimer = new QTimer(this); + statsTimer->setInterval(1000); + connect(statsTimer, &QTimer::timeout, this, &VideoView::sampleStats); +} + +VideoView::~VideoView() +{ + if (statsTimer) { + statsTimer->stop(); + } + if (player) { + player->stop(); + } + if (statsStream) { + statsStream->flush(); + delete statsStream; + statsStream = nullptr; + } + if (statsFile) { + statsFile->close(); + } +} + +void VideoView::play(const QString& uri, const QVariantMap& options) +{ + if (!player) { + qWarning() << "VideoView::play: QMediaPlayer not initialised"; + return; + } + + // Per-file options. Audio device first so any audible signal + // hits the right ALSA card from the first frame. + QStringList summary; + if (options.contains(QStringLiteral("audio-device"))) { + const QString alsaSpec = + options.value(QStringLiteral("audio-device")).toString(); + const QAudioDevice device = resolveAlsaDevice(alsaSpec); + audioOutput->setDevice(device); + summary << QStringLiteral("audio-device=%1").arg(alsaSpec); + } + + // QVideoWidget's QGraphicsVideoItem-equivalent transformation + // is exposed via ``setProperty("orientation", ...)`` on the + // sink in newer Qt; QVideoWidget directly supports rotation via + // the orientation hint metadata on the player. For 90/180/270 we + // pass the angle through to the player's video output rotation + // (Qt 6.7+ honours this on the gstreamer backend; on older + // backends the videosink rotates in software). Pi 4 sets this + // because eglfs has no compositor transform to inherit. + if (options.contains(QStringLiteral("video-rotate"))) { + bool ok = false; + const int angle = + options.value(QStringLiteral("video-rotate")).toInt(&ok); + if (ok && angle) { + videoWidget->setProperty("rotation", angle); + summary << QStringLiteral("video-rotate=%1").arg(angle); + } + } + + currentUri = uri; + playStartedAt.restart(); + framesDelivered = 0; + containerFps = 0.0; + writeStats( + QStringLiteral("LOADFILE"), + QStringLiteral("uri=%1 options={%2}") + .arg(uri, summary.join(QLatin1Char(' ')))); + + // Local-path URIs (e.g. ``/data/anthias_assets/abc.mp4``) come + // through as scheme-less strings; ``QUrl(uri)`` would parse + // them as relative URLs with no host/scheme and QMediaPlayer + // refuses to set them as the source. ``QUrl::fromLocalFile`` + // promotes a path to a proper ``file://`` URL. Anything already + // carrying a scheme (``http://``, ``file://``, ``rtsp://``) + // round-trips through ``QUrl(uri)`` untouched. + const QUrl source = uri.startsWith(QLatin1Char('/')) + ? QUrl::fromLocalFile(uri) + : QUrl(uri); + player->setSource(source); + player->play(); + if (statsTimer) { + statsTimer->start(); + } +} + +void VideoView::stop() +{ + if (!player) { + return; + } + if (statsTimer) { + statsTimer->stop(); + } + if (statsStream && !currentUri.isEmpty()) { + const qint64 elapsedMs = + playStartedAt.isValid() ? playStartedAt.elapsed() : -1; + writeStats( + QStringLiteral("STOP"), + QStringLiteral( + "uri=%1 elapsed_ms=%2 frames-delivered=%3 " + "position-ms=%4") + .arg(currentUri) + .arg(elapsedMs) + .arg(framesDelivered) + .arg(player->position())); + } + player->stop(); +} + +void VideoView::onPlaybackStateChanged(QMediaPlayer::PlaybackState state) +{ + if (state == QMediaPlayer::PlayingState) { + const QMediaMetaData meta = player->metaData(); + containerFps = meta.value(QMediaMetaData::VideoFrameRate).toReal(); + writeStats( + QStringLiteral("PLAYING"), + QStringLiteral( + "video-codec=%1 resolution=%2 container-fps=%3 " + "audio-codec=%4") + .arg(meta.value(QMediaMetaData::VideoCodec).toString(), + meta.value(QMediaMetaData::Resolution) + .toSize() + .isValid() + ? QStringLiteral("%1x%2") + .arg(meta.value(QMediaMetaData::Resolution) + .toSize() + .width()) + .arg(meta.value(QMediaMetaData::Resolution) + .toSize() + .height()) + : QStringLiteral("?"), + QString::number(containerFps), + meta.value(QMediaMetaData::AudioCodec).toString())); + } +} + +void VideoView::onMediaStatusChanged(QMediaPlayer::MediaStatus status) +{ + if (status == QMediaPlayer::EndOfMedia) { + const qint64 elapsedMs = + playStartedAt.isValid() ? playStartedAt.elapsed() : -1; + // Compare ``frames-delivered`` against the decoder-expected + // count (container_fps × elapsed_s). The gap = frames + // dropped on the way to the display, the same number mpv + // exposed as ``frame-drop-count``. Reported with the raw + // delivered count so the consumer can recompute if they + // disagree with the fps source. + const qreal expected = + containerFps > 0.0 ? containerFps * (elapsedMs / 1000.0) : -1.0; + const qint64 dropped = + expected > 0.0 + ? std::max(0, qRound(expected) - framesDelivered) + : -1; + writeStats( + QStringLiteral("END_FILE"), + QStringLiteral( + "uri=%1 elapsed_ms=%2 frames-delivered=%3 " + "expected=%4 dropped=%5") + .arg(currentUri) + .arg(elapsedMs) + .arg(framesDelivered) + .arg(qRound(expected)) + .arg(dropped)); + if (statsTimer) { + statsTimer->stop(); + } + emit videoEnded(); + } else if (status == QMediaPlayer::InvalidMedia) { + writeStats( + QStringLiteral("INVALID_MEDIA"), + QStringLiteral("uri=%1").arg(currentUri)); + } +} + +void VideoView::onErrorOccurred( + QMediaPlayer::Error error, const QString& message) +{ + writeStats( + QStringLiteral("ERROR"), + QStringLiteral("uri=%1 code=%2 message=%3") + .arg(currentUri, QString::number(static_cast(error)), + message)); + qWarning() << "VideoView::onErrorOccurred:" << error << message; +} + +void VideoView::sampleStats() +{ + if (!player || !statsStream) { + return; + } + const qint64 posMs = player->position(); + const qint64 elapsedMs = + playStartedAt.isValid() ? playStartedAt.elapsed() : -1; + const qreal expected = + containerFps > 0.0 ? containerFps * (elapsedMs / 1000.0) : -1.0; + const qint64 dropped = + expected > 0.0 + ? std::max(0, qRound(expected) - framesDelivered) + : -1; + writeStats( + QStringLiteral("SAMPLE"), + QStringLiteral( + "position-ms=%1 frames-delivered=%2 expected=%3 dropped=%4") + .arg(posMs) + .arg(framesDelivered) + .arg(qRound(expected)) + .arg(dropped)); +} + +void VideoView::onVideoFrameDelivered() +{ + ++framesDelivered; +} + +QAudioDevice VideoView::resolveAlsaDevice(const QString& alsaSpec) const +{ + QString needle = alsaSpec; + if (needle.startsWith(QLatin1String("alsa/"))) { + needle = needle.mid(5); + } + if (needle.isEmpty()) { + return QMediaDevices::defaultAudioOutput(); + } + + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice& dev : devices) { + const QString id = QString::fromUtf8(dev.id()); + if (id.contains(needle, Qt::CaseInsensitive) + || dev.description().contains(needle, Qt::CaseInsensitive)) { + return dev; + } + } + qWarning() << "VideoView::resolveAlsaDevice: no QAudioDevice" + << "matched" << alsaSpec << "— falling back to default"; + return QMediaDevices::defaultAudioOutput(); +} + +void VideoView::writeStats(const QString& kind, const QString& detail) +{ + if (!statsStream) { + return; + } + *statsStream << QDateTime::currentDateTimeUtc().toString(Qt::ISODate) + << QLatin1Char(' ') << kind + << QLatin1Char(' ') << detail + << QLatin1Char('\n'); + statsStream->flush(); +} diff --git a/src/anthias_webview/src/videoview.h b/src/anthias_webview/src/videoview.h new file mode 100644 index 000000000..8c8997691 --- /dev/null +++ b/src/anthias_webview/src/videoview.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QAudioOutput; +class QVideoSink; +class QVideoWidget; + +// VideoView owns the Qt 6 multimedia playback pipeline for the Qt6 +// boards (issue #2904). An earlier revision embedded libmpv via +// ``mpv_render_context`` into a ``QOpenGLWidget`` to eliminate the +// two-process DRM-master contention #2885 documented, and confirmed +// every HW decoder engaged (``drm-copy`` for HEVC via the rpi-hevc- +// dec block, ``v4l2m2m-copy`` for H.264 via bcm2835-codec) — but +// real-device measurement on Pi 4 left frame drops in the same +// 600-2973 / 60 s range as the subprocess baseline. The bottleneck +// is libmpv-render's chain of GL upload → ``QOpenGLWidget`` FBO → +// Qt compositor blit on top of QWebEngineView's surface; V3D 6.0's +// fillrate can't sustain 60 fps through that. A +// ``QOpenGLWindow`` direct-swap workaround crashed because eglfs +// is single-native-window-per-process. +// +// QtMultimedia + GStreamer offers a flatter pipeline: +// +// * QVideoWidget renders inside MainWindow's existing eglfs native +// window (no second QWindow → no eglfs violation). +// * Qt's GStreamer backend can route the v4l2 stateless decoders +// (``v4l2slh264dec``, ``v4l2slh265dec`` from rpi's rpt3 +// ``gstreamer1.0-plugins-bad``) directly to ``QVideoSink``, +// skipping the libmpv render API + FBO indirection. +// +// The MainWindow D-Bus surface (``playVideo`` / ``stopVideo`` / +// ``videoEnded``) and the Python option-dict contract are +// unchanged — clients see the same interface even though the +// underlying playback engine swapped. +class VideoView : public QWidget +{ + Q_OBJECT + +public: + explicit VideoView(QWidget* parent = nullptr); + ~VideoView() override; + + // Apply per-file options then hand the URI to QMediaPlayer. + // ``options`` keys map to gstreamer-flavoured tuning: + // + // * ``audio-device`` — ALSA device name (the same string the + // mpv era used; gstreamer's ALSA sink consumes it via + // ``QAudioDevice``). + // * ``video-rotate`` — int as string (0/90/180/270), Pi 4 only + // (cage / wayland boards still inherit rotation from + // wlr-randr). + // + // ``hwdec`` / ``vd-lavc-threads`` / ``video-sync`` from the + // libmpv option set are deliberately ignored — GStreamer's + // ``decodebin3`` auto-picks the best decoder element and handles + // sync internally. + void play(const QString& uri, const QVariantMap& options); + + // Stops the current file. QMediaPlayer's state stays alive so + // the next ``play()`` is a cheap setSource + play, not a + // pipeline rebuild. + void stop(); + +signals: + // Fires on ``QMediaPlayer::EndOfMedia``. Re-emitted by + // MainWindow as a D-Bus signal so Python can drop the + // ``time.sleep(duration)`` poll in a follow-up — not subscribed + // yet. + void videoEnded(); + +private slots: + void onPlaybackStateChanged(QMediaPlayer::PlaybackState state); + void onMediaStatusChanged(QMediaPlayer::MediaStatus status); + void onErrorOccurred(QMediaPlayer::Error error, const QString& message); + + // 1 Hz sampler. Writes the current position / duration / a + // rolling estimate of dropped frames to the stats log while a + // file is playing. + void sampleStats(); + + // Counts frames delivered to QVideoSink so the SAMPLE / END_FILE + // log lines can compare ``actually displayed`` against + // ``decoder-expected`` and report a dropped-frame estimate. The + // GStreamer backend uses ``qtvideosink`` which fires + // ``videoFrameChanged`` per display tick; counting those is the + // most direct measurement QtMultimedia exposes. + void onVideoFrameDelivered(); + +private: + // Resolve an ALSA device name (``alsa/sysdefault:CARD=vc4hdmi0``, + // produced by ``anthias_viewer.media_player.get_alsa_audio_device``) + // to the matching ``QAudioDevice`` from the system list. The + // ``alsa/`` prefix is stripped because QAudioDevice's id uses + // the raw device string. Falls back to the default output when + // no match is found so a typo doesn't silence playback. + QAudioDevice resolveAlsaDevice(const QString& alsaSpec) const; + // Append ``ISO-8601 KIND detail`` to ``/data/.anthias/mpv-stats.log``. + // Filename kept verbatim from the libmpv era so external tools + // grepping the BBB test bed don't need to track a rename. + void writeStats(const QString& kind, const QString& detail); + + QMediaPlayer* player = nullptr; + QAudioOutput* audioOutput = nullptr; + QVideoWidget* videoWidget = nullptr; + QHBoxLayout* videoLayout = nullptr; + + // Stats state. Same shape as the libmpv version so log + // consumers don't have to change schemas. + QFile* statsFile = nullptr; + QTextStream* statsStream = nullptr; + QTimer* statsTimer = nullptr; + QString currentUri; + QElapsedTimer playStartedAt; + qint64 framesDelivered = 0; + qreal containerFps = 0.0; +}; diff --git a/src/anthias_webview/src/view.cpp b/src/anthias_webview/src/view.cpp index baa81f877..dbd39a43b 100644 --- a/src/anthias_webview/src/view.cpp +++ b/src/anthias_webview/src/view.cpp @@ -132,6 +132,14 @@ View::View(QWidget* parent) : QWidget(parent) connect(webView2->page(), &QWebEnginePage::authenticationRequired, this, &View::handleAuthRequest); + // libmpv-backed video surface. Created hidden — only made + // visible when ``playVideo`` fires. The render context lives for + // the lifetime of this widget so repeated plays don't pay + // mpv_create / mpv_initialize cost on every asset. + videoView = new VideoView(this); + videoView->setVisible(false); + connect(videoView, &VideoView::videoEnded, this, &View::videoEnded); + networkManager = new QNetworkAccessManager(this); movie = nullptr; isAnimatedImage = false; @@ -174,6 +182,11 @@ void View::loadPage(const QString &uri) qDebug() << "Type: Webpage"; const quint64 requestId = ++loadGenerationId; + // Drop back to the web/image surface in case the previous asset + // was a video. Stops libmpv (frees its decoder threads + audio + // device) and hides the GL widget so the QWebEngineView paints + // are visible. + hideVideoSurface(); currentImage = QImage(); stopAnimation(); // Drop any per-asset reload timer left over from the previous @@ -234,6 +247,22 @@ void View::loadImage(const QString &preUri) qDebug() << "Type: Image"; const quint64 requestId = ++loadGenerationId; + // ``view_image('null')`` in src/anthias_viewer/__init__.py:495 + // is called AFTER ``media_player.play()`` to sweep any + // lingering web/image background out of the way of the new + // video — it is NOT a request to take down the freshly- + // started video surface. Skipping ``hideVideoSurface`` for the + // sentinel ``'null'`` URI keeps the just-started video alive; + // calling stop() here interrupted the QMediaPlayer mid-decoder + // init for Pi 5's Hantro G2 on 4K60 HEVC (~66 ms after the + // first PLAYING event) and left position stuck at 0 for the + // full 60 s asset_loop window. For a real image URI the prior + // video must still be torn down, so the call is preserved + // there. + if (preUri != QLatin1String("null")) { + hideVideoSurface(); + } + // Cancel any pending page load so we don't keep streaming a web // page in the background after the user has switched to image // playback. Without this the QWebEngineView would continue fetching @@ -391,6 +420,59 @@ void View::resizeEvent(QResizeEvent* event) QWidget::resizeEvent(event); webView1->setGeometry(rect()); webView2->setGeometry(rect()); + if (videoView) { + videoView->setGeometry(rect()); + } +} + +void View::playVideo(const QString &uri, const QVariantMap &options) +{ + qDebug() << "Type: Video"; + ++loadGenerationId; + + // Cancel any pending QWebEngineView load so a slow page-load + // completion doesn't race the video onto the screen mid-play. + // Mirrors the loadImage path's handling. + if (pageLoadConnection) { + QObject::disconnect(pageLoadConnection); + pageLoadConnection = QMetaObject::Connection{}; + } + stopReloadTimer(); + pendingReloadIntervalS = 0; + webView1->stop(); + webView2->stop(); + webView1->setVisible(false); + webView2->setVisible(false); + // Blank the image canvas so an old still doesn't flash through + // before the first mpv frame paints. + stopAnimation(); + currentImage = QImage(); + update(); + + if (!videoView) { + qWarning() << "View::playVideo: VideoView not constructed"; + return; + } + videoView->setGeometry(rect()); + videoView->raise(); + videoView->setVisible(true); + videoView->play(uri, options); +} + +void View::stopVideo() +{ + if (videoView) { + videoView->stop(); + } +} + +void View::hideVideoSurface() +{ + if (!videoView || !videoView->isVisible()) { + return; + } + videoView->stop(); + videoView->setVisible(false); } void View::handleAuthRequest(const QUrl& requestUrl, QAuthenticator*) diff --git a/src/anthias_webview/src/view.h b/src/anthias_webview/src/view.h index d4dae141d..5b0571f7c 100644 --- a/src/anthias_webview/src/view.h +++ b/src/anthias_webview/src/view.h @@ -8,6 +8,9 @@ #include #include #include +#include + +#include "videoview.h" class View : public QWidget { @@ -20,6 +23,15 @@ class View : public QWidget void loadPage(const QString &uri); void loadImage(const QString &uri); void setReloadInterval(int seconds); + // Hands the URI + option dict to libmpv and switches visibility + // so VideoView is on top of the QWebEngineView pair / image + // canvas. Pauses background URL loads so a parked QWebEngineView + // doesn't keep streaming while video plays. + void playVideo(const QString &uri, const QVariantMap &options); + void stopVideo(); + +signals: + void videoEnded(); protected: void paintEvent(QPaintEvent* event) override; @@ -35,6 +47,11 @@ private slots: void loadAsStaticImage(const QByteArray& data); void setupAnimation(); void switchToNextWebView(); + // Hides VideoView and re-enables the web/image surface. Called + // by loadPage / loadImage so a switch from video back to a web + // page or image doesn't leave the GL widget on top of the + // QWebEngineView. + void hideVideoSurface(); QNetworkAccessManager* networkManager; QImage currentImage; @@ -43,6 +60,11 @@ private slots: bool isAnimatedImage; quint64 loadGenerationId; + // libmpv-backed video widget (issue #2904). Sibling of the web / + // image widgets — visibility is toggled rather than re-parented + // so the GL render context survives across plays. + VideoView* videoView; + // Dual web view system QWebEngineView* webView1; QWebEngineView* webView2; diff --git a/src/anthias_webview/tests/test_videoview.cpp b/src/anthias_webview/tests/test_videoview.cpp new file mode 100644 index 000000000..4a26ab66b --- /dev/null +++ b/src/anthias_webview/tests/test_videoview.cpp @@ -0,0 +1,117 @@ +// QtTest unit tests for VideoView's QtMultimedia pipeline +// (issue #2904). The tests instantiate VideoView with no display +// (``QT_QPA_PLATFORM=offscreen``) and assert on the publicly +// observable behaviour: +// +// * Construction succeeds and the inner QMediaPlayer exists. +// * ``play()`` updates source / playback state without throwing. +// * ``stop()`` is idempotent (callable before and after play). +// * Options dict keys are accepted forgivingly (no crash on +// unknown / empty option values). +// * The audio device resolver falls back to the system default +// when a typo'd ALSA spec doesn't match anything. +// +// Real-device validation handles the gstreamer pipeline end-to-end +// (decoder engagement + drop counts via /data/.anthias/mpv-stats.log +// on the BBB test bed). QtMultimedia's pipeline doesn't fully +// initialise under the offscreen platform because there's no GL +// context to upload frames into, so we don't try to play media — +// just exercise the API surface. + +#include +#include +#include +#include +#include +#include + +#include "videoview.h" + + +class TestVideoView : public QObject +{ + Q_OBJECT + +private slots: + // Smoke test: constructing VideoView creates the QMediaPlayer + // + QVideoWidget pair and the stats logger initialises (writes + // an ``INIT`` line on hosts where ``/data/.anthias`` is + // writeable — the test host typically has no such directory, in + // which case the warning fires and statsStream stays null; both + // states are acceptable). + void constructorBuildsPlayer() + { + VideoView view; + QVERIFY(view.findChild() != nullptr); + } + + // ``stop()`` must be callable on a freshly-built VideoView + // (defensive: asset_loop may call stop() during a + // rotate-from-image path without ever having played a video on + // this widget). Also callable repeatedly without crashing. + void stopIsIdempotent() + { + VideoView view; + view.stop(); + view.stop(); + QVERIFY(true); + } + + // ``play()`` with an empty options dict shouldn't crash. The + // URI is non-existent — gstreamer will error out asynchronously + // via ``errorOccurred`` once it tries to resolve, but the + // setSource + play call itself must return cleanly. + void playWithEmptyOptionsDoesNotCrash() + { + VideoView view; + view.play(QStringLiteral("file:///nonexistent.mp4"), QVariantMap()); + view.stop(); + QVERIFY(true); + } + + // ``audio-device`` option is forgiving: a typo'd ALSA name + // falls back to the system default audio output rather than + // crashing. + void playFallsBackOnUnknownAudioDevice() + { + VideoView view; + QVariantMap options; + options["audio-device"] = QStringLiteral( + "alsa/sysdefault:CARD=NotARealCard"); + view.play(QStringLiteral("file:///nonexistent.mp4"), options); + view.stop(); + QVERIFY(true); + } + + // ``video-rotate`` accepts 0 / 90 / 180 / 270 as strings (the + // Python side stringifies via ``str(rotation)``). The widget + // exposes the value through QObject::property("rotation") for + // backends that honour it. + void playPassesVideoRotateValues() + { + for (const QString& angle : + {QStringLiteral("90"), QStringLiteral("180"), + QStringLiteral("270")}) { + VideoView view; + QVariantMap options; + options["video-rotate"] = angle; + view.play(QStringLiteral("file:///nonexistent.mp4"), options); + // The rotation property lives on the videoWidget child; + // verify it round-tripped via the property system. + QWidget* videoWidget = nullptr; + for (QObject* child : view.children()) { + if (child->inherits("QVideoWidget")) { + videoWidget = qobject_cast(child); + break; + } + } + QVERIFY(videoWidget != nullptr); + QCOMPARE(videoWidget->property("rotation").toInt(), + angle.toInt()); + view.stop(); + } + } +}; + +QTEST_MAIN(TestVideoView) +#include "test_videoview.moc" diff --git a/src/anthias_webview/tests/tests.pro b/src/anthias_webview/tests/tests.pro new file mode 100644 index 000000000..9045ea196 --- /dev/null +++ b/src/anthias_webview/tests/tests.pro @@ -0,0 +1,24 @@ +# QtTest-based unit tests for AnthiasWebview's QtMultimedia +# pipeline (issue #2904). Built and run via bin/test_webview_cpp.sh +# inside a container or on a host with Qt 6 (qt6-multimedia-dev) + +# the gstreamer plugin set installed. Not wired into the main +# viewer Docker image; the production Dockerfile only builds +# AnthiasWebview.pro (no test sources or test runner are shipped +# to devices). + +TEMPLATE = app +TARGET = AnthiasWebviewTests + +QT += core gui testlib widgets multimedia multimediawidgets dbus +CONFIG += c++17 console testcase + +# Re-use the production sources verbatim — tests instantiate +# VideoView directly. ``main.cpp`` is excluded because QTEST_MAIN +# provides its own entry point. +SOURCES += \ + ../src/videoview.cpp \ + test_videoview.cpp + +HEADERS += ../src/videoview.h + +INCLUDEPATH += ../src diff --git a/tests/test_media_player.py b/tests/test_media_player.py index c3c966cd9..5bc0d0cb0 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -1,5 +1,4 @@ import logging -import subprocess from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch @@ -19,6 +18,7 @@ class _MPVFixtures: player: MPVMediaPlayer mock_settings: Any + mock_bus: Any @pytest.fixture @@ -26,329 +26,225 @@ def mpv() -> Iterator[_MPVFixtures]: fixtures = _MPVFixtures() fixtures.player = MPVMediaPlayer() + # MPVMediaPlayer now hands play() / stop() through a pydbus + # proxy of the AnthiasWebview C++ process (issue #2904 — + # libmpv lives inside the webview). Inject a Mock so tests + # can assert on the options dict shipped over D-Bus without + # standing up a real D-Bus session. + fixtures.mock_bus = MagicMock() + patch_bus = patch( + 'anthias_viewer.media_player._browser_bus', fixtures.mock_bus + ) + # Production wraps each option value in GLib.Variant('s', ...) + # so pydbus can marshal it as ``a{sv}``. Tests bypass that wrap + # so assertions like ``options['hwdec'] == 'auto-copy'`` keep + # working — the wrap is integration concern, the option + # composition is what the tests cover. + patch_marshal = patch( + 'anthias_viewer.media_player._marshal_dbus_options', + side_effect=lambda opts: opts, + ) patch_settings = patch('anthias_viewer.media_player.settings') patch_device_type = patch( 'anthias_viewer.media_player.get_device_type', return_value='pi4' ) - # Default-mock the ffprobe-based codec probe so tests that pin - # DEVICE_TYPE to 'pi4-64'/'pi5' don't try to spawn a real ffprobe - # subprocess inside the test harness. Returning '' makes - # _pi_hwdec_for_uri fall back to 'auto-copy', which preserves the - # pre-ffprobe behaviour the legacy tests assert on. Tests that - # care about a specific dispatch (h264 → v4l2m2m-copy etc.) layer - # a sharper patch on top of this default. - patch_probe = patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='', - ) + patch_bus.start() + patch_marshal.start() fixtures.mock_settings = patch_settings.start() fixtures.mock_settings.__getitem__.return_value = 'hdmi' patch_device_type.start() - patch_probe.start() try: yield fixtures finally: + patch_bus.stop() + patch_marshal.stop() patch_settings.stop() patch_device_type.stop() - patch_probe.stop() - - -@patch( - 'anthias_viewer.media_player._detect_hdmi_audio_device', - return_value='sysdefault:CARD=vc4hdmi0', -) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_invokes_popen_with_expected_args_on_pi4_64( - 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-64'}): - mpv.player.play() - - mock_popen.assert_called_once_with( - [ - 'mpv', - '--no-terminal', - '--vo=drm', - '--hwdec=auto-copy', - '--video-sync=display-resample', - '--drm-mode=1920x1080@60', - '--vd-lavc-threads=4', - '--audio-device=alsa/sysdefault:CARD=vc4hdmi0', - '--', - 'file:///test/video.mp4', - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_pins_1080p_mode_on_pi4_64( - mock_popen: Any, mpv: _MPVFixtures -) -> None: - # Pi 4 stays on --vo=drm (Qt linuxfb + mpv DRM master juggling) - # so the --drm-mode pin is still meaningful — it sidesteps the - # CPU zimg upscale to 4K that the A72 can't keep up with. - mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': '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] - - -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='h264', -) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_picks_v4l2m2m_for_h264_on_pi4_64( - mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures -) -> None: - # Pi 4 H.264 dispatches to v4l2m2m-copy (V3D V4L2 M2M); mpv's - # auto-copy whitelist excludes v4l2m2m-copy so we have to set it - # explicitly. - mpv.player.set_asset('file:///test/h264.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): - mpv.player.play() - args, _ = mock_popen.call_args - assert '--hwdec=v4l2m2m-copy' in args[0] - assert '--hwdec=auto-copy' not in args[0] - - -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='h264', -) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_picks_auto_copy_for_h264_on_pi5( - mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures -) -> None: - # Pi 5 has no upstream-mpv H.264 hwdec (Hantro G1 isn't exposed - # through v4l2-request in mpv 0.40). Passing v4l2m2m-copy here - # would just log "Could not find a valid device" errors before - # silently SW-falling-back, so we send auto-copy and let mpv's - # default selector pick (which finds nothing for H.264 on Pi 5 - # and falls to software cleanly). The asset processor re-encodes - # H.264 → HEVC on Pi 5 at upload time so this path is only hit - # for pre-existing assets during the rollout window. - mpv.player.set_asset('file:///test/h264.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'pi5'}): - mpv.player.play() - args, _ = mock_popen.call_args - assert '--hwdec=auto-copy' in args[0] - assert '--hwdec=v4l2m2m-copy' not in args[0] -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='hevc', -) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_picks_drm_copy_for_hevc_on_pi( - mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures -) -> None: - # HEVC on Pi 4 / Pi 5 goes through FFmpeg's v4l2_request_hevc, - # exposed in mpv as drm-copy. - for device_type in ('pi4-64', 'pi5'): - mock_popen.reset_mock() - mpv.player.set_asset('file:///test/hevc.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): - mpv.player.play() - args, _ = mock_popen.call_args - assert '--hwdec=drm-copy' in args[0], device_type +def _last_play_options(bus: Any) -> dict[str, str]: + """Extract the options dict from the most recent ``playVideo`` call.""" + bus.playVideo.assert_called() + _, kwargs = bus.playVideo.call_args + args = bus.playVideo.call_args.args + # The Python side calls bus.playVideo(uri, options) — positional. + assert len(args) == 2, args + return args[1] -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='', -) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_falls_back_to_auto_copy_when_probe_fails_on_pi( - mock_popen: Any, _mock_probe: Any, mpv: _MPVFixtures -) -> None: - # If ffprobe can't read the codec (missing file, timeout, …) - # the Pi dispatch must fall back to auto-copy rather than - # passing a bogus --hwdec= value. - mpv.player.set_asset('file:///test/missing.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): - mpv.player.play() - args, _ = mock_popen.call_args - assert '--hwdec=auto-copy' in args[0] +def _last_play_uri(bus: Any) -> str: + args = bus.playVideo.call_args.args + assert len(args) == 2, args + return args[0] @patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='h264', + 'anthias_viewer.media_player._detect_hdmi_audio_device', + return_value='sysdefault:CARD=vc4hdmi0', ) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_does_not_probe_on_non_pi( - mock_popen: Any, mock_probe: Any, mpv: _MPVFixtures -) -> None: - # ffprobe shouldn't run on x86 / arm64 — they go through - # --hwdec=auto-copy unconditionally and probing adds latency - # before every mpv launch. - for device_type in ('x86', 'arm64'): - mock_probe.reset_mock() - mock_popen.reset_mock() - mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): - mpv.player.play() - mock_probe.assert_not_called() - args, _ = mock_popen.call_args - assert '--hwdec=auto-copy' in args[0], device_type - - -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_tunes_decoder_threads_on_pi5( - mock_popen: Any, mpv: _MPVFixtures +def test_play_calls_browser_bus_with_expected_options_on_pi4_64( + _mock_detect: Any, mpv: _MPVFixtures ) -> None: - # Pi 5 keeps software-decode threading. mpv ignores --drm-mode - # under cage (no DRM master), and the connector's native mode is - # what cage runs at — V3D 7.1 has enough bandwidth headroom for - # the 4K composite + scale. mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'pi5'}): - mpv.player.play() - - args, _ = mock_popen.call_args - 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_omits_pi_tuning_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 '--drm-mode=1920x1080@60' not in args[0] - assert '--vd-lavc-threads=4' not in args[0] - - -@pytest.mark.parametrize('device_type', ['x86', 'arm64', 'pi5']) -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_uses_wayland_vo_under_cage( - mock_popen: Any, mpv: _MPVFixtures, device_type: str -) -> None: - # x86 / arm64 / pi5 run under cage (a wlroots kiosk compositor); - # cage holds DRM master, so --vo=drm would be denied. These - # boards must route through --vo=gpu --gpu-context=wayland. - mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': device_type}): + with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): 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] - assert '--gpu-context=drm' not in args[0] + assert _last_play_uri(mpv.mock_bus) == 'file:///test/video.mp4' + options = _last_play_options(mpv.mock_bus) + assert options['audio-device'] == 'alsa/sysdefault:CARD=vc4hdmi0' -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_uses_drm_vo_on_pi4_64( - mock_popen: Any, mpv: _MPVFixtures -) -> None: - # Pi 4 stays on Qt linuxfb (no cage); mpv uses --vo=drm because - # --vo=gpu --gpu-context=drm needs Mesa GBM to hold DRM master - # persistently, which contends with Qt linuxfb's framebuffer use - # ("Failed to acquire DRM master: Permission denied"). +def test_play_omits_libmpv_era_options(mpv: _MPVFixtures) -> None: + # The libmpv era options dict carried ``hwdec``, ``video-sync``, + # ``vd-lavc-threads``, ``vo``, ``drm-mode``. Under + # QtMultimedia + gstreamer the decoder + sync are handled + # inside the backend pipeline, so the Python side must not + # send any of those — defensive, in case the C++ side starts + # forwarding unknown keys to gstreamer as element properties + # and one happens to collide. mpv.player.set_asset('file:///test/video.mp4', 30) with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}): mpv.player.play() - - args, _ = mock_popen.call_args - assert '--vo=drm' in args[0] - assert '--vo=gpu' not in args[0] + options = _last_play_options(mpv.mock_bus) + for legacy in ('hwdec', 'video-sync', 'vd-lavc-threads', 'vo', 'drm-mode'): + assert legacy not in options, legacy -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_uses_default_alsa_device_on_arm64( - mock_popen: Any, mpv: _MPVFixtures -) -> None: +def test_play_uses_default_alsa_device_on_arm64(mpv: _MPVFixtures) -> None: # No portable per-SoC HDMI card name exists across Rockchip / - # Allwinner / Amlogic, so arm64 defers to ALSA's - # `default` device rather than the Pi-firmware vc4hdmi* / HID - # cards the regular dispatch would otherwise pick. + # Allwinner / Amlogic, so arm64 defers to ALSA's `default` + # device rather than the Pi-firmware vc4hdmi* / HID cards the + # regular dispatch would otherwise pick. mpv.player.set_asset('file:///test/video.mp4', 30) with patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}): mpv.player.play() - args, _ = mock_popen.call_args - assert '--audio-device=alsa/default' in args[0] + options = _last_play_options(mpv.mock_bus) + assert options['audio-device'] == 'alsa/default' -@patch('anthias_viewer.media_player.subprocess.Popen') def test_play_uses_local_audio_device_when_configured( - mock_popen: Any, mpv: _MPVFixtures + mpv: _MPVFixtures, ) -> None: mpv.mock_settings.__getitem__.return_value = 'local' mpv.player.set_asset('file:///test/video.mp4', 30) mpv.player.play() - args, _ = mock_popen.call_args - assert '--audio-device=alsa/plughw:CARD=Headphones' in args[0] + options = _last_play_options(mpv.mock_bus) + assert options['audio-device'] == 'alsa/plughw:CARD=Headphones' -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_play_reloads_settings_each_call( - mock_popen: Any, mpv: _MPVFixtures -) -> None: +def test_play_reloads_settings_each_call(mpv: _MPVFixtures) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) mpv.player.play() mpv.mock_settings.load.assert_called_once() -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_is_playing_returns_true_when_process_running( - mock_popen: Any, mpv: _MPVFixtures -) -> None: - mock_process = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process +def test_play_no_bus_logs_and_clears_state(mpv: _MPVFixtures) -> None: + # If the webview proxy hasn't been injected (failed handshake, + # crashed webview that hasn't respawned), play() must not + # latch is_playing() into a false-true state. + with patch('anthias_viewer.media_player._browser_bus', None): + mpv.player.set_asset('file:///test/video.mp4', 30) + mpv.player.play() + assert not mpv.player.is_playing() + +def test_play_bus_failure_clears_playing_state(mpv: _MPVFixtures) -> None: + # A pydbus transport error on play() (webview just crashed, + # SIGPIPE on the bus, …) must reset the local flag so a + # downstream is_playing() call doesn't report a phantom video. + mpv.mock_bus.playVideo.side_effect = RuntimeError('bus down') mpv.player.set_asset('file:///test/video.mp4', 30) mpv.player.play() + assert not mpv.player.is_playing() - assert mpv.player.is_playing() +def test_is_playing_returns_true_after_play(mpv: _MPVFixtures) -> None: + mpv.player.set_asset('file:///test/video.mp4', 30) + mpv.player.play() + assert mpv.player.is_playing() -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_is_playing_returns_false_when_process_finished( - mock_popen: Any, mpv: _MPVFixtures -) -> None: - mock_process = MagicMock() - mock_process.poll.return_value = 0 - mock_popen.return_value = mock_process +def test_is_playing_returns_false_after_stop(mpv: _MPVFixtures) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) mpv.player.play() - + mpv.player.stop() assert not mpv.player.is_playing() -def test_is_playing_returns_false_when_no_process(mpv: _MPVFixtures) -> None: +def test_is_playing_returns_false_before_play(mpv: _MPVFixtures) -> None: assert not mpv.player.is_playing() -@patch('anthias_viewer.media_player.subprocess.Popen') -def test_stop_terminates_process(mock_popen: Any, mpv: _MPVFixtures) -> None: - mock_process = MagicMock() - mock_popen.return_value = mock_process - +def test_stop_calls_browser_bus_stop_video(mpv: _MPVFixtures) -> None: mpv.player.set_asset('file:///test/video.mp4', 30) mpv.player.play() mpv.player.stop() + mpv.mock_bus.stopVideo.assert_called_once() - mock_process.terminate.assert_called_once() - assert mpv.player.process is None + +def test_stop_without_play_is_safe(mpv: _MPVFixtures) -> None: + # Defensive: viewer code may call stop() on an idle player + # (e.g. when rotating from a video back to a webpage and the + # video had already self-finished). Must not raise even if + # play() was never called. + mpv.player.stop() + mpv.mock_bus.stopVideo.assert_called_once() + + +def test_stop_without_bus_is_safe(mpv: _MPVFixtures) -> None: + with patch('anthias_viewer.media_player._browser_bus', None): + # No bus, no exception. + mpv.player.stop() + assert not mpv.player.is_playing() + + +def test_marshal_dbus_options_wraps_in_glib_variant() -> None: + """pydbus refuses to coerce ``str`` to ``GLib.Variant`` when the + slot is declared ``a{sv}`` ("Expected GLib.Variant, but got str" + at runtime). Regression: a viewer deploy without this wrap + surfaced the error on every video play. Verify the wrap actually + produces ``GLib.Variant`` instances so the contract with + ``MainWindow::playVideo`` (declared ``QVariantMap``) holds. + """ + import anthias_viewer.media_player as mp + + out = mp._marshal_dbus_options({'hwdec': 'auto-copy', 'foo': 'bar'}) + # Conftest stubs ``gi.repository.GLib`` to a MagicMock on hosts + # without PyGObject; ``GLib.Variant('s', 'auto-copy')`` returns a + # MagicMock whose ``call_args`` records ('s', 'auto-copy'). On + # the real viewer image GLib is real and Variant returns an + # actual variant — but the call signature is the same. + from gi.repository import GLib + + # Both keys must be present and each value must have been the + # result of a ``GLib.Variant('s', )`` call (so pydbus + # picks the ``a{sv}`` marshalling path). + assert set(out) == {'hwdec', 'foo'} + GLib.Variant.assert_any_call('s', 'auto-copy') + GLib.Variant.assert_any_call('s', 'bar') + + +def test_set_browser_bus_injects_module_state() -> None: + # Smoke test the injection hook the viewer uses after the + # AnthiasWebview D-Bus handshake. Re-injecting a fresh proxy + # (post-webview-restart) must replace the previous one rather + # than appending. + import anthias_viewer.media_player as mp + + saved = mp._browser_bus + try: + first = MagicMock() + second = MagicMock() + mp.set_browser_bus(first) + assert mp.get_browser_bus() is first + mp.set_browser_bus(second) + assert mp.get_browser_bus() is second + finally: + mp.set_browser_bus(saved) @pytest.fixture @@ -773,35 +669,32 @@ def _rotated_mpv_settings(rotation: int) -> Any: @pytest.mark.parametrize('device_type', ['x86', 'arm64', 'pi5']) @pytest.mark.parametrize('rotation', [0, 90, 180, 270]) -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='', -) @patch( 'anthias_viewer.media_player._detect_hdmi_audio_device', return_value='sysdefault:CARD=vc4hdmi0', ) -@patch('anthias_viewer.media_player.subprocess.Popen') def test_mpv_never_passes_video_rotate_under_cage( - mock_popen: Any, _mock_detect: Any, - _mock_probe: Any, rotation: int, device_type: str, ) -> None: """x86 / arm64 / pi5 run under cage and inherit the compositor transform via wlr-randr (issue #2856 — driven from - src/anthias_viewer/__init__.py). Passing --video-rotate to mpv - would double-rotate, so MPVMediaPlayer must never add it on - those boards.""" + src/anthias_viewer/__init__.py). Passing ``video-rotate`` on + those boards would double-rotate, so the option must be + omitted regardless of the Settings page value.""" player = MPVMediaPlayer() - # Patch get_device_type alongside DEVICE_TYPE so - # get_alsa_audio_device() takes a deterministic branch on the - # host — patching only DEVICE_TYPE while leaving get_device_type - # at the fixture default ('pi4') would route x86/arm64 through - # _detect_hdmi_audio_device() and stat /sys/class/drm. + mock_bus = MagicMock() + # See history: we patch get_device_type alongside DEVICE_TYPE + # because get_alsa_audio_device() reads /sys/class/drm when + # device_type resolves to pi4/pi5. audio_device_type = device_type if device_type == 'pi5' else 'x86' with ( + patch('anthias_viewer.media_player._browser_bus', mock_bus), + patch( + 'anthias_viewer.media_player._marshal_dbus_options', + side_effect=lambda opts: opts, + ), patch( 'anthias_viewer.media_player.settings', _rotated_mpv_settings(rotation), @@ -814,27 +707,30 @@ def test_mpv_never_passes_video_rotate_under_cage( ): player.set_asset('file:///test/video.mp4', 30) player.play() - args, _ = mock_popen.call_args - assert not any(arg.startswith('--video-rotate') for arg in args[0]) + options = _last_play_options(mock_bus) + assert 'video-rotate' not in options @pytest.mark.parametrize('rotation', [90, 180, 270]) -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='', -) @patch( 'anthias_viewer.media_player._detect_hdmi_audio_device', return_value='sysdefault:CARD=vc4hdmi0', ) -@patch('anthias_viewer.media_player.subprocess.Popen') def test_mpv_passes_video_rotate_on_pi4_64( - mock_popen: Any, _mock_detect: Any, _mock_probe: Any, rotation: int + _mock_detect: Any, rotation: int ) -> None: - """Pi 4 stays on Qt linuxfb (no cage), so there's no compositor - transform to inherit — mpv has to apply rotation itself.""" + """Pi 4 (eglfs, no compositor) has no transform plumbing — the + video pipeline has to apply rotation itself via the + ``video-rotate`` option (forwarded to ``QVideoWidget.rotation`` + on the C++ side).""" player = MPVMediaPlayer() + mock_bus = MagicMock() with ( + patch('anthias_viewer.media_player._browser_bus', mock_bus), + patch( + 'anthias_viewer.media_player._marshal_dbus_options', + side_effect=lambda opts: opts, + ), patch( 'anthias_viewer.media_player.settings', _rotated_mpv_settings(rotation), @@ -847,26 +743,29 @@ def test_mpv_passes_video_rotate_on_pi4_64( ): player.set_asset('file:///test/video.mp4', 30) player.play() - args, _ = mock_popen.call_args - assert f'--video-rotate={rotation}' in args[0] + options = _last_play_options(mock_bus) + assert options['video-rotate'] == str(rotation) @patch( 'anthias_viewer.media_player._detect_hdmi_audio_device', return_value='sysdefault:CARD=vc4hdmi0', ) -@patch( - 'anthias_viewer.media_player._probe_video_codec', - return_value='', -) -@patch('anthias_viewer.media_player.subprocess.Popen') def test_mpv_skips_video_rotate_at_zero_on_pi4_64( - mock_popen: Any, _mock_probe: Any, _mock_detect: Any + _mock_detect: Any, ) -> None: - """0° must NOT emit --video-rotate=0 — keeps the CLI surface - unchanged for the 99% of operators who never touch the dropdown.""" + """0° must NOT emit ``video-rotate=0`` — keeps the D-Bus surface + unchanged for the 99% of operators who never touch the dropdown, + so the video pipeline falls back to its own default rather than + being told to rotate by zero.""" player = MPVMediaPlayer() + mock_bus = MagicMock() with ( + patch('anthias_viewer.media_player._browser_bus', mock_bus), + patch( + 'anthias_viewer.media_player._marshal_dbus_options', + side_effect=lambda opts: opts, + ), patch( 'anthias_viewer.media_player.settings', _rotated_mpv_settings(0), @@ -879,8 +778,8 @@ def test_mpv_skips_video_rotate_at_zero_on_pi4_64( ): player.set_asset('file:///test/video.mp4', 30) player.play() - args, _ = mock_popen.call_args - assert not any(arg.startswith('--video-rotate') for arg in args[0]) + options = _last_play_options(mock_bus) + assert 'video-rotate' not in options def test_proxy_reset_clears_cached_instance(reset_media_proxy: None) -> None: @@ -894,32 +793,27 @@ def test_proxy_reset_clears_cached_instance(reset_media_proxy: None) -> None: fake.stop.assert_called_once() -def test_pi_hwdec_dispatch_matches_upload_gate() -> None: - """The upload gate (``processing._HW_DECODE_VIDEO_CODECS``) and - the viewer's per-codec mpv dispatch (``_PI_HWDEC_BY_CODEC``) must - not drift. If the gate accepts a codec on a board, the viewer - must have an explicit hwdec entry for it — otherwise the operator - uploads a clip that passes validation, then plays back through - ``auto-copy`` and quietly software-decodes, defeating the gate. - - The reverse direction (viewer claims to hardware-decode a codec - the gate rejects) is just as bad — the viewer path becomes dead - code while the gate refuses every upload that would exercise it. - - Boards present in the gate but absent from ``_PI_HWDEC_BY_CODEC`` - (``pi2``, ``pi3``, ``x86``) are intentional: Pi 2/3 use VLC, x86 - falls through to mpv ``--hwdec=auto-copy`` which picks vaapi-copy - on Intel iGPUs. Those paths don't need an explicit table entry.""" +def test_upload_gate_codecs_are_h264_or_hevc() -> None: + """QtMultimedia + gstreamer's ``decodebin3`` auto-selects an + appropriate decoder element at playback time (rpi's + ``v4l2slh264dec`` / ``v4l2slh265dec`` for h264 / hevc on Pi + boards; VA-API on x86; software fallback elsewhere). The + application no longer maintains an explicit per-codec hwdec + table — but the upload gate from PR #2885 still restricts + accepted codecs to whichever ones the board can play. This + test asserts the gate's accepted codecs are entirely the + h264 / hevc family GStreamer's decoders cover, so a clip that + passes the gate is by construction playable. New codecs (av1, + vp9, mpeg2) need to be both gated on the server side and + confirmed available as gstreamer elements before the gate is + relaxed.""" from anthias_server.processing import _HW_DECODE_VIDEO_CODECS - from anthias_viewer.media_player import _PI_HWDEC_BY_CODEC - for board, viewer_codecs in _PI_HWDEC_BY_CODEC.items(): - assert board in _HW_DECODE_VIDEO_CODECS, ( - f'viewer dispatches {board!r} but the upload gate has no ' - f'entry — every upload to that board would be rejected.' - ) - gate_codecs = _HW_DECODE_VIDEO_CODECS[board] - assert frozenset(viewer_codecs) == gate_codecs, ( - f'codec mismatch for {board!r}: ' - f'viewer={sorted(viewer_codecs)} gate={sorted(gate_codecs)}' + supported = {'h264', 'hevc'} + for board, codecs in _HW_DECODE_VIDEO_CODECS.items(): + extra = set(codecs) - supported + assert not extra, ( + f'{board!r} gate accepts {sorted(extra)} which is not in ' + f'the gstreamer h264/hevc dispatch — add the matching ' + f'element to the dispatch or remove from the gate.' ) diff --git a/tools/image_builder/utils.py b/tools/image_builder/utils.py index b67596e39..e91ecf3e7 100644 --- a/tools/image_builder/utils.py +++ b/tools/image_builder/utils.py @@ -259,15 +259,25 @@ def get_viewer_context(board: str, target_platform: str) -> dict[str, Any]: if is_qt6: # Shared Qt 6 runtime for every Qt6 board (pi4-64, pi5, x86, - # arm64). mpv handles video for all of them via MPVMediaPlayer; - # VLC is deliberately *not* installed because MediaPlayerProxy - # never routes Qt6 boards to it (would be ~80-100 MB of dead - # weight). + # arm64). VideoView uses QMediaPlayer + QVideoWidget from + # qt6-multimedia. Qt 6.8 dropped its gstreamer backend + # upstream (only ``libffmpegmediaplugin.so`` ships in + # ``/usr/lib/.../qt6/plugins/multimedia/``); decode goes + # through libavcodec directly. The +rpt1 ``ffmpeg`` / + # ``libav*`` packages pinned in _rpt1-ffmpeg-pin.j2 carry + # ``--enable-v4l2-request`` + ``--enable-v4l2-m2m`` on + # Pi/arm64, so libavcodec engages the same rpi-hevc-dec + + # bcm2835-codec hardware that libmpv era used — no + # gstreamer plugin set needed. VLC is deliberately not + # installed because MediaPlayerProxy never routes Qt6 + # boards to it. viewer_extra_apt_dependencies.extend( [ - 'mpv', + 'libqt6multimedia6', + 'libqt6multimediawidgets6', 'qt6-base-dev', 'qt6-image-formats-plugins', + 'qt6-multimedia-dev', 'qt6-webengine-dev', ] )