Commit Graph

3 Commits

Author SHA1 Message Date
Viktor Petersson
1f438d2af0 perf(viewer): render video via QML VideoOutput in a QQuickWidget (#2975)
* perf(viewer): render video via QML VideoOutput in a QQuickWidget

- replace the QGraphicsVideoItem-on-raster-QGraphicsView substrate:
  QVideoFrame::toImage did an RHI offscreen render + GPU->CPU
  readback per frame, capping presentation at 8.3 fps (Pi 4) /
  10-12 fps (Pi 5) with a saturated GUI thread while HW decode ran
  fine (issue 2967). Validated on both testbeds: Pi 4 30.0 fps
  presented at 64% total CPU, Pi 5 26.6 fps at 13-35%
- VideoOutput keeps frames on the GPU: scene-graph textures with
  shader YUV->RGB, composited through the same QQuickRenderControl
  FBO machinery QWebEngineView already uses (eglfs-safe, inherits
  whole-screen rotation -- re-validated under QT_QPA_EGLFS_ROTATION)
- log frames-rendered (QQuickWindow::afterRendering) next to
  frames-delivered in playback-stats so presentation-side drops are
  visible -- the sink-only counter is how the 8 fps regression
  shipped unnoticed; connection is retried from play() so the
  counter can't silently stay dead
- fail hard (qFatal) when the QML scene is unavailable instead of
  decoding video to nowhere: crash-respawn is supervised and loud,
  a silent black-screen kiosk is not
- video-rotate maps to VideoOutput.orientation (still a defensive
  no-op; every platform rotates the whole screen)
- ship qt6-declarative-dev + qml6-module-qtquick/-qtmultimedia in
  the Qt6 viewer images; drop the now-unused multimediawidgets
- run the C++ tests with QT_QUICK_BACKEND=software so the QML scene
  loads under the offscreen platform

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(image-builder): align gstreamer-drop version comment to Qt 6.5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:06:30 +02:00
Viktor Petersson
3091fec349 feat(api,viewer): viewer REST shim + rename AnthiasWebview → AnthiasViewer (#2907)
* feat(api,viewer): viewer REST shim + rename AnthiasWebview → AnthiasViewer

- Add GET /api/v2/viewer/playlist returning server-evaluated active
  assets, next deadline, and ``now``; gated by internal token.
- Add GET /api/v2/viewer/settings exposing only the viewer-relevant
  settings subset (shuffle/show_splash/screen_rotation/audio_output/
  debug_logging) so the internal-auth path doesn't surface operator
  credentials.
- Rename the C++ binary AnthiasWebview → AnthiasViewer (.pro file,
  Dockerfile copies, sh.Command spawn, test runner) and the D-Bus
  service anthias.webview → anthias.viewer (atomic because both
  endpoints ship in the same image).
- Migrate runtime state paths /data/.local/share/AnthiasWebview and
  /data/.cache/AnthiasWebview to AnthiasViewer with a one-shot
  symlink so existing devices keep QtWebEngine cookies / local-
  storage across the upgrade.
- Source tree src/anthias_webview/ stays put; the directory rename
  is deferred to Phase 5 when the Python viewer package is deleted.

First step of GH #2906; sets up the contract the C++ viewer will
consume in Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api,viewer): address review feedback on viewer REST shim

- ViewerPlaylistViewV2 now reloads anthias.conf on read so an
  in-flight settings PATCH doesn't shuffle off a stale cached
  value — mirrors what ViewerSettingsViewV2 already did.
- AssetSerializerV2.get_is_active accepts ``now`` via context so
  ViewerPlaylistViewV2 can render the ``is_active`` field against
  the same instant the filter used; closes the millisecond race
  where a row right on a window boundary could be returned in
  ``assets`` while its ``is_active`` re-evaluated to False.
- Simplify the windowed-deadline-cap test assertion: parse the
  ISO timestamp and compare datetimes directly instead of the
  awkward dual-format string-prefix check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): use https in viewer API fixture URI

Silences SonarCloud python:S5332 on tests/test_viewer_api.py.
The fixture URIs are never fetched — they just satisfy the
``uri`` field on Asset.objects.create — but matching the existing
test_recheck_endpoint.py convention keeps the linter quiet without
sprinkling NOSONAR comments through test data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(viewer): drop QtWebEngine state symlink-migration on rename

Validated on real hardware: a fresh AnthiasViewer cache rebuilds
itself on the next page load, so the bookkeeping to preserve
cookies / local-storage across the AnthiasWebview → AnthiasViewer
rename isn't worth the code. Upgraded devices just get fresh state
dirs alongside the (now-orphaned) old AnthiasWebview tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:31:01 +02:00
Viktor Petersson
cc92a714e4 feat(viewer,webview): embed QtMultimedia in AnthiasWebview, eliminate two-process DRM contention + Pi 4 drops (#2905)
* feat(viewer,webview): embed QtMultimedia in AnthiasWebview, eliminate Pi 4 frame drops (#2904)

Move video playback inside AnthiasWebview's Qt 6 process via
QtMultimedia (QMediaPlayer + QGraphicsVideoItem). The libmpv
subprocess goes away — a single Qt process owns the eglfs/wayland
surface, so the two-process DRM-master contention #2885 documented
(600-2800 vo drops per 60 s clip on Pi 4) no longer applies. The
D-Bus contract on MainWindow (playVideo / stopVideo / videoEnded)
is preserved so Python still calls a stable interface even though
the playback engine swapped underneath.

Architecture

* src/anthias_webview/src/videoview.{cpp,h} — new VideoView wraps
  QMediaPlayer + QGraphicsVideoItem + QAudioOutput. Qt 6.5 dropped
  the upstream gstreamer media backend so Debian Trixie ships only
  the ffmpeg-backed libffmpegmediaplugin.so; decode runs through
  libavcodec against the +rpt1 libav* packages already pinned in
  docker/_rpt1-ffmpeg-pin.j2 (which carry --enable-v4l2-request /
  --enable-v4l2-m2m so rpi-hevc-dec, bcm2835-codec, Hantro G2,
  rkvdec all engage automatically).
* QGraphicsView + QGraphicsScene + QGraphicsVideoItem (not
  QVideoWidget) is the rendering substrate so video-rotate actually
  rotates the displayed frames — QGraphicsItem::setRotation is
  honoured by the painter, whereas QVideoWidget has no rotation
  property and a setProperty("rotation", angle) shortcut would
  store a dynamic value nothing reads.
* src/anthias_webview/src/view.cpp — adds playVideo / stopVideo
  surface-switching alongside loadPage / loadImage; loadImage skips
  hideVideoSurface() for the 'null' sentinel so a freshly-started
  video isn't torn down ~66 ms after the first PLAYING event by the
  view_image('null') call that follows media_player.play() in
  asset_loop.
* src/anthias_viewer/media_player.py — MPVMediaPlayer.play() routes
  through pydbus to the AnthiasWebview proxy. Per-codec hwdec
  dispatch + ffprobe codec sniff are gone; libavcodec auto-engages
  the right decoder. _marshal_dbus_options picks the GLib.Variant
  signature by Python type so int / bool / float options round-trip
  cleanly. video-rotate is sent as int.

Operational

* Pi 4 switches QT_QPA_PLATFORM from linuxfb to eglfs (QtMultimedia
  needs a GL context for the QGraphicsVideoItem painter).
  QT_QPA_EGLFS_KMS_CONFIG pins 1080p so V3D 6.0 doesn't have to
  composite Chromium + the video graphics view on top of the
  connector's native 4K. QT_SCALE_FACTOR=1 pins CSS-px to
  physical-px on the 1080p surface.
* tools/image_builder/utils.py — drops libmpv2 / mpv from the viewer
  image, adds libqt6multimedia6 / libqt6multimediawidgets6 /
  qt6-multimedia-dev / qt6-image-formats-plugins.
* /data/.anthias/playback-stats.log (renamed from mpv-stats.log) is
  capped at 8 MB; truncate on viewer start past the cap so a long-
  running 15 GB SD-card device can't fill up with 1 Hz SAMPLE rows.
* VideoView::resolveAlsaDevice extracts CARD=<name> from the ALSA
  spec and matches the QAudioDevice id on that segment; logs the
  resolved id at INFO so multi-HDMI Pi 4 / Pi 5 mismatches are
  visible from journalctl.

Validation

Real-device measurements via /data/.anthias/playback-stats.log on
the BBB pack (1080p / 4K, 30 / 60 fps, H.264 + HEVC), median across
multi-cycle plays in the PR comments. Pi 4 BBB 1080p60 H.264 dropped
from 2973 frames/min on the libmpv subprocess baseline to 0 with
QtMultimedia. 12 h mixed-media burn-in: zero crashes, zero early-
stops, no RSS leak across x86 / Pi 4 / Pi 5. 3 h asset-churn (120
toggles × 3 boards): zero <100 ms stops, drops stable. Rock Pi 4
arm64 image is built and identical to the validated set; the
testbed itself is SSH-unreliable so its end-to-end run is deferred.

C++ QtTest suite (8 cases) covers VideoView construction, stop
idempotency, empty / unknown audio device handling, and
QGraphicsItem::rotation() actually receiving the angle for cardinal
rotations and snapping non-cardinal angles to 0. Python suite
(63 cases) covers options-dict composition, D-Bus marshalling for
str / int / bool / float, settings reload, codec gate symmetry,
proxy reset, and VLC fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(viewer,webview): polish stale comments from second PR review

* MPVMediaPlayer.__init__ comment no longer says the C++ side owns
  a libmpv handle — it owns QMediaPlayer + QGraphicsVideoItem.
* Rename _build_mpv_options to _build_video_options. The function
  composes options for QtMultimedia now; the "mpv" in the name is
  vestigial. Class names (MPVMediaPlayer / MediaPlayerProxy) are
  left alone — those are the public D-Bus contract.
* LoadedMedia comment in videoview.cpp now reflects Qt 6's actual
  semantics: "metadata available, playback can start" — first
  decoded frame lands a hair later via videoFrameChanged. Starting
  the elapsed-ms clock here is still a few-ms approximation of
  "first frame on screen", which is the intent.
* _marshal_dbus_options return type tightened from bare ``dict`` to
  ``dict[str, Any]`` for symmetry with the input annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): marshal test works with real PyGObject + tighten typing

CI ships PyGObject so ``gi.repository.GLib`` is the real module — the
prior test relied on conftest's MagicMock stub (which only kicks in
when ``gi`` is missing) to invoke ``assert_any_call`` on GLib.Variant.
On the real Variant class that's an AttributeError.

Patch ``gi.repository.GLib.Variant`` to a sentinel-returning callable
inside the test scope so the assertions work with either the stub
host or the real PyGObject host. The marshal still picks signatures
by Python type (``s`` / ``i`` / ``b`` / ``d``); the test now asserts
on the per-key tuple rather than the spy.

mypy errors:
* Narrow ``_last_play_options`` / ``_last_play_uri`` return values
  via ``isinstance`` so they don't fall through Any (no ``# type:
  ignore``, no ``cast``).
* Add ``gi`` / ``gi.*`` to the mypy-overrides ``ignore_missing_imports``
  set so the conftest stub doesn't break the type-check on hosts
  without PyGObject.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:38:16 +02:00