Browse Source

fix: preserve AR by constraining only width in NV12 videoscale capsfilter

Constraining both width AND height caused GStreamer to stretch the video
to fill the target box, distorting the aspect ratio when the box was not
the same AR as the source (e.g. 720x600 vs a 16:9 source).

Fix: only constrain width in the capsfilter (height=(int)[2,2160]).
GStreamer then picks the height from the source's native DAR, naturally
preserving aspect ratio without relying on add-borders.
_fit_frame_to_viewport centres the resulting frame in the SDL viewport.
main
Matteo Benedetto 1 week ago
parent
commit
f1e7448aad
  1. 36
      src/r36s_dlna_browser/player/gstreamer_backend.py

36
src/r36s_dlna_browser/player/gstreamer_backend.py

@ -431,26 +431,24 @@ class GStreamerBackend(PlayerBackend):
# Hardware decode (NV12): insert a videoscale → capsfilter chain inside a
# GstBin before the appsink so playbin accepts it as a single video-sink.
#
# videoscale(method=nearest-neighbour, add-borders=True) scales the
# decoded source to fit within the video area while preserving the
# original aspect ratio. Black NV12 borders fill any leftover space
# (letterbox / pillarbox), avoiding any stretch distortion.
# videoscale(method=nearest-neighbour) scales the decoded source to the
# video-area width while letting GStreamer pick the output height from
# the source's native aspect ratio. Constraining only the width (not
# both width and height) means GStreamer will never stretch the video:
# it always preserves the source DAR. The resulting frame is then
# centred in the SDL viewport by _fit_frame_to_viewport() which adds
# the necessary letterbox/pillarbox margins through SDL_RenderCopy.
# Nearest-neighbour skips ~56% of source rows so only ~44% of source
# cache lines are fetched; Python memmove drops from ~32 ms to ~1 ms.
#
# capsfilter — enforces the output NV12 dimensions.
# Use the actual video area inside the HUD (full window minus margins)
# so the scale target matches the drawable region exactly.
# Dimensions are rounded down to even numbers (NV12 chroma subsampling
# requires both width and height to be divisible by 2).
# capsfilter — enforces format=NV12 and output width; height is left as
# a range so GStreamer can choose the correct value from the source AR.
vp_w, vp_h, vp_top, vp_bottom, vp_left, vp_right = self._viewport
video_w = max(2, vp_w - vp_left - vp_right)
video_h = max(2, vp_h - vp_top - vp_bottom)
video_w = max(4, vp_w - vp_left - vp_right)
scale_w = (video_w // 2) * 2
scale_h = (video_h // 2) * 2
if scale_w < 2 or scale_h < 2:
scale_w, scale_h = 640, 480
log.info("NV12 appsink: videoscale(nearest) → %dx%d before appsink", scale_w, scale_h)
if scale_w < 4:
scale_w = 640
log.info("NV12 appsink: videoscale(nearest) → width=%d (AR-preserving) before appsink", scale_w)
scale = self._gst.ElementFactory.make("videoscale", "vscale")
capsfilter = self._gst.ElementFactory.make("capsfilter", "vcaps")
@ -463,15 +461,13 @@ class GStreamerBackend(PlayerBackend):
# nearest-neighbour: accesses only the source pixels needed for each
# output sample (strided reads), skipping ~56% of source rows entirely.
# add-borders=True: letterbox/pillarbox to preserve the source aspect
# ratio instead of stretching to fill the target dimensions.
# Height is unconstrained (range 2–2160) so GStreamer picks whatever
# height preserves the source aspect ratio for the chosen width.
scale.set_property("method", 0)
scale.set_property("add-borders", True)
capsfilter.set_property(
"caps",
self._gst.Caps.from_string(
f"video/x-raw,format=NV12,width={scale_w},height={scale_h},"
f"pixel-aspect-ratio=1/1"
f"video/x-raw,format=NV12,width={scale_w},height=(int)[2,2160]"
),
)

Loading…
Cancel
Save