Skip to content

fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video#3004

Merged
vpetersson merged 8 commits into
masterfrom
fix-pi3-gst-player-2987
Jun 7, 2026
Merged

fix(viewer): aspect-fit, gapless looping, and 30fps cap for pi1/2/3 video#3004
vpetersson merged 8 commits into
masterfrom
fix-pi3-gst-player-2987

Conversation

@vpetersson

@vpetersson vpetersson commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Issues Fixed

Part of #2987 (the Pi 3 symptoms: stretched portrait videos, clips freezing and getting cut off at the start/end, jerky 50/60 fps playback).

Description

Moves Qt5-linuxfb (pi1/2/3) video playback from the bash+gst-launch relaunch loop into a small in-process GStreamer helper (anthias_viewer/gst_fbdev_player.py):

  • Stretch fix: a CAPS-event pad probe reads the decoder's native dimensions + PAR and pins aspect-fit caps (pixel-aspect-ratio=1/1) on a capsfilter, so the bcm2835 ISP scales aspect-correct and fbdevsink centers the frame. The old fb-sized forced caps made v4l2convert fill the screen and park the distortion in a PAR that fbdevsink ignores.
  • Loop-gap fix: playbin about-to-finish re-queues the same URI for a gapless loop instead of rebuilding the pipeline each iteration (measured 0.4–1.7 s per loop on a Pi 4, more on a Pi 3 — all eaten out of the fixed slot duration while the last frame sat frozen). Flush-seek-on-EOS and a fresh rebuild remain as fallbacks.
  • Judder mitigation: videorate drop-only=true max-rate=30 drops 50/60 fps sources to an even half-cadence before the ISP + framebuffer blit (the chain sustains ~40 fps at 1080p on a Pi 3).
  • Audio robustness (found by integrated testing): the ALSA device is pre-flighted before being wired in — a missing card (e.g. HDMI audio disabled) degrades to silent video instead of killing the slot, and any pipeline error with audio enabled rebuilds a fresh video-only playbin (a playbin whose sink activation failed does not reliably restart).
  • Visible-resolution fix (found by integrated testing): fb geometry now comes from the FBIOGET_VSCREENINFO ioctl (xres/yres — the fields fbdevsink centers against) instead of sysfs virtual_size, which can be larger than the scanned-out mode.
  • The framebuffer is zeroed at startup so letterbox borders are black; the helper is executed by path (not -m) so the package __init__ never imports in the child.

No image changes needed: python3-gi + gir1.2-gstreamer-1.0 already ship in the pi1/2/3 viewer image.

Validation

Component level (shipped armhf latest-pi3 image on the Pi 4 testbed, binaries run natively): caps negotiation incl. 90° rotation, gapless looping across clip boundaries, SIGTERM → exit 0. Reproduced the original stretch (1080x1920 → 3840x2160 par 81/256) with the shipped pipeline first.

Integrated stack (full viewer container — asset_loop → view_video → helper spawn — from this branch on the Pi 4 testbed, armhf, faked device-tree model; webview stubbed with a D-Bus shim due to host-specific 32-bit QtWebEngine instability that does not occur on real Pi 3 silicon):

  1. Audio-fail → silent video: missing ALSA card caught by pre-flight, video plays. ✅
  2. Portrait aspect-fit: 1080x1920 → 608x1080 negotiated AND verified at the pixel level — framebuffer rows read [656px black][608px content][656px black]. ✅
  3. Audio positive + AC3: AAC clip plays with the ALSA PCM in state: RUNNING; an AC3 track (no decoder shipped) is skipped gracefully, video continues. ✅
  4. Stability: 11 slot transitions / 6 min across three assets — 0 errors, 0 EOS fallbacks (all loops gapless), 1 player process, no strays. ✅

Outstanding on real Pi 3 hardware (Pi 3B+ reachable, currently headless — no /dev/fb0 without a display): the fbdev paint path, the literal vc4hdmi card open, and VideoCore IV decode rates. Will be run as soon as a display/dummy is attached; the audio pre-flight + VPU decode probes run there next regardless.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices. (integrated stack as above; real-Pi 3 display path pending a connected display — see Validation)
  • I have tested my changes for x86 devices. (n/a — pi1/2/3-only code path)
  • I added a documentation for the changes I have made (when necessary).

🤖 Generated with Claude Code

…ideo

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>
@vpetersson vpetersson requested a review from a team as a code owner June 6, 2026 21:05
@vpetersson vpetersson self-assigned this Jun 6, 2026
@vpetersson vpetersson requested a review from Copilot June 6, 2026 21:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves Raspberry Pi 1/2/3 (Qt5 linuxfb) video playback by moving the GStreamer looping/playback logic from an external bash + gst-launch respawn loop into an in-process Python helper, enabling aspect-correct “fit” scaling, gapless looping, and a 30fps output cap to reduce judder.

Changes:

  • Replace gst-launch respawn looping with a dedicated gst_fbdev_player.py helper process that manages a playbin pipeline and looping behavior in-process.
  • Add unit tests for the helper’s pure functions (aspect-fit math, caps building, argv parsing, framebuffer clear).
  • Update existing media player tests and documentation to reflect the new execution model and playback behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_media_player.py Updates command-building and spawn tests to validate invoking the helper with CLI flags instead of gst-launch.
tests/test_gst_fbdev_player.py Adds unit tests for helper utilities (fit-dim math, caps strings, sink description, fb clearing).
src/anthias_viewer/media_player.py Switches Pi1/2/3 video playback to spawn the helper by path with explicit CLI parameters.
src/anthias_viewer/gst_fbdev_player.py New helper implementing gapless looping, aspect-fit caps pinning, 30fps cap, and framebuffer clearing.
docs/board-enablement.md Documents the new helper-based playback path and rationale (aspect-fit, looping, 30fps cap).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_viewer/gst_fbdev_player.py
Comment thread src/anthias_viewer/gst_fbdev_player.py Outdated
Comment thread src/anthias_viewer/gst_fbdev_player.py
vpetersson and others added 7 commits June 6, 2026 21:12
- 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>
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>
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>
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>
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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 7, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/anthias_viewer/media_player.py
@vpetersson vpetersson merged commit 9863d8c into master Jun 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants