mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
* 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>
156 lines
5.1 KiB
Python
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
|