Files
Anthias/tests/test_gst_fbdev_player.py
Viktor Petersson 9863d8c9d3 fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video (#3004)
* fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video

Fixes the issue #2987 regressions on the Qt5 linuxfb boards by moving
playback from a bash gst-launch relaunch loop into a small in-process
GStreamer helper (anthias_viewer/gst_fbdev_player.py):

- Portrait/4:3 videos no longer stretch to the framebuffer: a CAPS-event
  pad probe reads the decoder's native dims + PAR and pins aspect-fit
  caps (pixel-aspect-ratio=1/1) on the capsfilter, so the bcm2835 ISP
  scales aspect-correct and fbdevsink centers the frame. The previous
  fb-sized forced caps parked the distortion in a PAR that fbdevsink
  ignores (reproduced on-device: 1080x1920 -> 3840x2160 par 81/256).
- Clips no longer freeze/cut at loop boundaries: playbin about-to-finish
  re-queues the same URI for a gapless loop instead of rebuilding the
  whole pipeline per iteration (0.4-1.7 s per loop measured on a Pi 4,
  several seconds on a Pi 3, all eaten out of the fixed slot duration).
  Flush-seek on EOS and NULL->PLAYING restart remain as fallbacks.
- 50/60 fps sources drop to an even 30 fps cadence up front (videorate
  drop-only) instead of juddering on irregular late-frame drops; the
  decode->ISP->memcpy chain sustains ~40 fps at 1080p on a Pi 3.
- The framebuffer is zeroed at startup so letterbox borders are black
  rather than remnants of the previous asset.
- The helper runs by path (not -m) so the package __init__ (Django
  settings, redis, D-Bus) never imports in the child; validated e2e on
  the armhf image: negotiation, rotation, looping, SIGTERM exit 0.

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

* fix(viewer): address review feedback on the fbdev player helper

- Correct the module docstring: the helper is executed by file path,
  not -m (the package __init__ must not import in the child)
- Fail fast with a clear log line when the GStreamer python bindings
  are missing instead of crashing with a traceback
- Clear the framebuffer in scanline-sized chunks so a 4K console
  doesn't peak a ~33 MB allocation on a 512 MB board

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

* fix(viewer): degrade to silent video when the audio branch fails

Integrated testbed run surfaced a wholesale-failure mode the relaunch
loop also had: a broken audio branch killed the video with it. Two
real-world triggers: the ALSA card is absent (HDMI audio disabled in
config.txt -> no vc4hdmi), and an undecodable audio codec (AC3 -
a52dec lives in plugins-ugly, not shipped). Retry once with
GST_PLAY_FLAG_AUDIO cleared on both the synchronous start failure
(alsasink can't reach READY) and the first async pipeline error; a
genuine video error recurs on the retry and still exits non-zero.

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

* fix(viewer): read the visible fb resolution via FBIOGET_VSCREENINFO

Integrated testbed run surfaced a divergence the sysfs read hides:
sysfs virtual_size reports xres_virtual/yres_virtual, which can be
larger than the scanned-out mode (panning / double-buffer configs —
observed live: visible 1920x1080, virtual 3840x2160). fbdevsink
centers/crops against varinfo.xres/yres, so scaling to the virtual
size paints mostly off-screen. Query the same ioctl fbdevsink uses;
keep the sysfs read (and the 1080p default) as fallbacks for hosts
without fb access.

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

* fix(viewer): swap the audio sink for fakesink on the video-only retry

Clearing GST_PLAY_FLAG_AUDIO is not sufficient: an element set on the
audio-sink property remains a playsink child and is still state-synced
with the pipeline, so a failing alsasink failed the retry too
(observed live on the testbed). Replace it with a fakesink when
degrading to silent video.

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

* fix(viewer): pre-flight the ALSA device and rebuild on audio failure

The integrated testbed run showed two things the previous in-place
retry missed:
- a playbin whose sink activation failed does not reliably restart
  after NULL: the video-only retry failed instantly on the reused
  element with no further GStreamer error;
- alsasink opens the PCM on NULL->READY, so a missing card is
  detectable synchronously before it can poison playbin's whole
  sink activation.

So: pre-flight the device with a standalone alsasink and only wire
the audio branch when it opens; on any pipeline error with audio
enabled (e.g. an undecodable AC3 track mid-preroll), tear down and
rebuild a fresh video-only playbin instead of restarting the errored
one. Genuine video errors recur on the rebuilt pipeline and still
exit non-zero.

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

* fix(viewer): satisfy mypy on the gi-typed returns

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-07 16:00:33 +02:00

156 lines
5.1 KiB
Python

"""Unit tests for the pure helpers in anthias_viewer.gst_fbdev_player.
The GStreamer runtime (``gi``) is only imported inside ``main()``, so
everything here runs on a host without PyGObject — only the aspect-fit
math, caps composition, argv parsing, and fb-clear I/O are exercised.
"""
import logging
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from anthias_viewer.gst_fbdev_player import (
MAX_OUTPUT_FPS,
build_fit_caps_string,
build_sink_description,
clear_framebuffer,
compute_fit_dims,
parse_args,
)
logging.disable(logging.CRITICAL)
@pytest.mark.parametrize(
('src', 'fb', 'expected'),
[
# Same aspect → fullscreen.
((1920, 1080), (1920, 1080), (1920, 1080)),
# Portrait into landscape fb → pillarbox, height pinned. This
# is the issue #2987 stretch case: 1080x1920 must NOT fill
# 1920x1080.
((1080, 1920), (1920, 1080), (608, 1080)),
# 4:3 into 16:9 → pillarbox.
((1440, 1080), (1920, 1080), (1440, 1080)),
# Wider than fb aspect → letterbox, width pinned.
((2560, 1080), (1920, 1080), (1920, 810)),
# Upscale of a small source still fits the fb.
((640, 360), (1920, 1080), (1920, 1080)),
# Odd results round down to even for the ISP.
((1080, 1920), (3840, 2160), (1214, 2160)),
],
)
def test_compute_fit_dims_square_pixels(
src: tuple[int, int],
fb: tuple[int, int],
expected: tuple[int, int],
) -> None:
assert compute_fit_dims(*src, 1, 1, *fb) == expected
def test_compute_fit_dims_honours_anamorphic_par() -> None:
# 720x576 with 16/11 PAR (DVD widescreen) displays as ~16:9, so it
# letterboxes a 1920x1080 fb at full width.
width, height = compute_fit_dims(720, 576, 16, 11, 1920, 1080)
assert width == 1920
assert abs(height - 1056) <= 2
def test_compute_fit_dims_degrades_to_fullscreen_on_bad_input() -> None:
assert compute_fit_dims(0, 1080, 1, 1, 1920, 1080) == (1920, 1080)
# Garbage PAR is treated as square rather than crashing playback.
assert compute_fit_dims(1920, 1080, 0, 1, 1920, 1080) == (1920, 1080)
def test_build_fit_caps_pins_par() -> None:
# pixel-aspect-ratio=1/1 is the load-bearing part: without it the
# converter satisfies a forced WxH by stashing the distortion in
# the PAR, which fbdevsink ignores (the issue #2987 stretch).
caps = build_fit_caps_string('RGB16', 608, 1080)
assert caps == (
'video/x-raw,format=RGB16,width=608,height=1080,pixel-aspect-ratio=1/1'
)
def test_build_fit_caps_without_dims_only_pins_format_and_par() -> None:
assert build_fit_caps_string('BGRx') == (
'video/x-raw,format=BGRx,pixel-aspect-ratio=1/1'
)
def _args(rotation: int = 0) -> Any:
return parse_args(
[
'--uri',
'file:///test/video.mp4',
'--fb-width',
'1920',
'--fb-height',
'1080',
'--fb-format',
'RGB16',
'--rotation',
str(rotation),
'--audio-device',
'sysdefault:CARD=vc4hdmi',
]
)
def test_sink_description_is_hw_pipeline_with_rate_cap() -> None:
desc = build_sink_description(_args())
# videorate ahead of the converter so 50/60 fps sources drop to an
# even cadence before the ISP + framebuffer blit (which sustain
# ~40 fps at 1080p on a Pi 3) instead of juddering on late frames.
assert desc.startswith(
f'videorate drop-only=true max-rate={MAX_OUTPUT_FPS}'
)
assert 'v4l2convert name=fit_convert' in desc
assert 'capsfilter name=fit_caps' in desc
assert 'fbdevsink device=/dev/fb0' in desc
# Unrotated panel → no videoflip, pipeline stays fully hardware.
assert 'videoflip' not in desc
def test_sink_description_rotation_adds_videoflip() -> None:
desc = build_sink_description(_args(rotation=90))
assert 'videoflip method=clockwise' in desc
assert desc.index('videoflip') < desc.index('v4l2convert')
def test_parse_args_rejects_non_cardinal_rotation() -> None:
with pytest.raises(SystemExit):
_args(rotation=45)
def test_clear_framebuffer_writes_stride_times_height() -> None:
written = bytearray()
def fake_open(path: str, *open_args: Any, **kwargs: Any) -> Any:
if path.endswith('/stride'):
data = '3840\n'
elif path.endswith('/virtual_size'):
data = '1920,1080\n'
else:
handle = MagicMock()
handle.write = written.extend
return MagicMock(
__enter__=lambda s: handle, __exit__=lambda *a: None
)
return MagicMock(
__enter__=lambda s: MagicMock(read=lambda: data),
__exit__=lambda *a: None,
)
with patch('builtins.open', side_effect=fake_open):
assert clear_framebuffer() is True
assert len(written) == 3840 * 1080
assert not any(written)
def test_clear_framebuffer_is_best_effort() -> None:
with patch('builtins.open', side_effect=OSError('no fb')):
assert clear_framebuffer() is False