Browse Source

test: section 8 — lazy texture + width-only NV12 caps (matches app AR fix)

- Remove fixed SDL8_SCALE_H; capsfilter now uses width-only (same as app)
  so GStreamer derives height from source DAR.
- Texture created lazily on first frame with correct dimensions instead of
  a fixed 640x480 that would mismatch an AR-preserving 640x360 frame.
- SDL_RenderCopy now letterboxes the frame into the window (preserves AR)
  instead of stretching to fill, matching what _fit_frame_to_viewport does.
- [texture] log line reports actual w x h and AR ratio for verification.
main
Matteo Benedetto 1 week ago
parent
commit
761707b45a
  1. 65
      tests/test_video_playback_device.py

65
tests/test_video_playback_device.py

@ -345,9 +345,11 @@ print()
# ── 8. End-to-end SDL rendering benchmark ───────────────────────────────── # ── 8. End-to-end SDL rendering benchmark ─────────────────────────────────
# #
# This section replicates what the app does frame-by-frame: # This section replicates what the app does frame-by-frame:
# 1. GStreamer appsink (same videoscale GstBin as the app) delivers NV12 at 640×480 # 1. GStreamer appsink (same videoscale GstBin as the app) — width-only NV12
# capsfilter so GStreamer preserves the source DAR when choosing height.
# 2. Python memmoves the mapped buffer into a ctypes array ← timed # 2. Python memmoves the mapped buffer into a ctypes array ← timed
# 3. SDL_UpdateNVTexture uploads Y + UV planes ← timed # 3. SDL_UpdateNVTexture uploads Y + UV planes into a lazily-created
# texture whose dimensions match the actual decoded frame. ← timed
# 4. SDL_RenderCopy blits the texture to the window ← timed # 4. SDL_RenderCopy blits the texture to the window ← timed
# #
# Desync and drops will be visible here because we do real SDL rendering. # Desync and drops will be visible here because we do real SDL rendering.
@ -363,8 +365,7 @@ elif not test_url:
_warn("Skipped — no URL. Provide a URL as the first argument.") _warn("Skipped — no URL. Provide a URL as the first argument.")
else: else:
SDL8_SECONDS = 20 # how long to run SDL8_SECONDS = 20 # how long to run
SDL8_SCALE_W = 640 SDL8_SCALE_W = 640 # width fed into capsfilter; height derived from source DAR
SDL8_SCALE_H = 480
try: try:
import ctypes import ctypes
@ -387,7 +388,7 @@ else:
window = sdl2.SDL_CreateWindow( window = sdl2.SDL_CreateWindow(
b"R36S playback test", b"R36S playback test",
sdl2.SDL_WINDOWPOS_UNDEFINED, sdl2.SDL_WINDOWPOS_UNDEFINED, sdl2.SDL_WINDOWPOS_UNDEFINED, sdl2.SDL_WINDOWPOS_UNDEFINED,
SDL8_SCALE_W, SDL8_SCALE_H, SDL8_SCALE_W, SDL8_SCALE_W, # square hint; KMSDRM uses native res anyway
sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP | sdl2.SDL_WINDOW_SHOWN, sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP | sdl2.SDL_WINDOW_SHOWN,
) )
if not window: if not window:
@ -411,17 +412,12 @@ else:
sdl2.SDL_GetWindowSize(window, ctypes.byref(w_actual), ctypes.byref(h_actual)) sdl2.SDL_GetWindowSize(window, ctypes.byref(w_actual), ctypes.byref(h_actual))
print(f" SDL window size: {w_actual.value}×{h_actual.value}") print(f" SDL window size: {w_actual.value}×{h_actual.value}")
texture = sdl2.SDL_CreateTexture( # Texture is created lazily on the first frame so dimensions match
renderer, # the actual GStreamer output (height is AR-derived, not fixed).
sdl2.SDL_PIXELFORMAT_NV12, texture = None
sdl2.SDL_TEXTUREACCESS_STREAMING, texture_size = (0, 0) # (w, h) of the current texture
SDL8_SCALE_W, SDL8_SCALE_H,
)
if not texture:
_fail(f"SDL_CreateTexture NV12: {sdl2.SDL_GetError().decode()}")
raise RuntimeError("SDL texture")
_ok(f"SDL init OK — window {w_actual.value}×{h_actual.value}, NV12 texture {SDL8_SCALE_W}×{SDL8_SCALE_H}") _ok(f"SDL init OK — window {w_actual.value}×{h_actual.value} (texture: lazy init)")
# ── GStreamer pipeline (mirrors _create_appsink) ───────────────────── # ── GStreamer pipeline (mirrors _create_appsink) ─────────────────────
gi.require_version("GstVideo", "1.0") gi.require_version("GstVideo", "1.0")
@ -454,6 +450,8 @@ else:
pass pass
# Build videoscale GstBin (nearest-neighbour) → capsfilter → appsink. # Build videoscale GstBin (nearest-neighbour) → capsfilter → appsink.
# Only width is fixed in the capsfilter; GStreamer derives height from
# the source's display aspect ratio (same strategy as _create_appsink).
video_sink8 = appsink8 video_sink8 = appsink8
if _hw_active: if _hw_active:
scale8 = Gst.ElementFactory.make("videoscale", "vs8") scale8 = Gst.ElementFactory.make("videoscale", "vs8")
@ -461,7 +459,7 @@ else:
if scale8 and cfilt8: if scale8 and cfilt8:
scale8.set_property("method", 0) # nearest-neighbour scale8.set_property("method", 0) # nearest-neighbour
cfilt8.set_property("caps", Gst.Caps.from_string( cfilt8.set_property("caps", Gst.Caps.from_string(
f"video/x-raw,format=NV12,width={SDL8_SCALE_W},height={SDL8_SCALE_H}")) f"video/x-raw,format=NV12,width={SDL8_SCALE_W}"))
bin8 = Gst.Bin.new("vscale-bin8") bin8 = Gst.Bin.new("vscale-bin8")
bin8.add(scale8); bin8.add(cfilt8); bin8.add(appsink8) bin8.add(scale8); bin8.add(cfilt8); bin8.add(appsink8)
scale8.link(cfilt8); cfilt8.link(appsink8) scale8.link(cfilt8); cfilt8.link(appsink8)
@ -470,7 +468,7 @@ else:
gp.set_active(True) gp.set_active(True)
bin8.add_pad(gp) bin8.add_pad(gp)
video_sink8 = bin8 video_sink8 = bin8
print(f" [pipeline] videoscale(nearest){SDL8_SCALE_W}×{SDL8_SCALE_H} NV12 bin active") print(f" [pipeline] videoscale(nearest) width={SDL8_SCALE_W} NV12 bin active (height=AR-derived)")
else: else:
appsink8.set_property("caps", Gst.Caps.from_string( appsink8.set_property("caps", Gst.Caps.from_string(
"video/x-raw,format=NV12;video/x-raw,format=BGRA")) "video/x-raw,format=NV12;video/x-raw,format=BGRA"))
@ -620,9 +618,22 @@ else:
fs.dirty = False fs.dirty = False
frame_n += 1 frame_n += 1
# --- Lazy texture creation / resize ---
if texture_size != (w8, h8):
if texture:
sdl2.SDL_DestroyTexture(texture)
texture = sdl2.SDL_CreateTexture(
renderer,
sdl2.SDL_PIXELFORMAT_NV12,
sdl2.SDL_TEXTUREACCESS_STREAMING,
w8, h8,
)
texture_size = (w8, h8)
print(f" [texture] created {w8}×{h8} NV12 (AR={w8/h8:.3f})")
# --- SDL_UpdateNVTexture upload --- # --- SDL_UpdateNVTexture upload ---
t_up0 = time.monotonic() t_up0 = time.monotonic()
if fmt8 == "NV12" and y_size8 > 0: if fmt8 == "NV12" and y_size8 > 0 and texture:
y_ptr = ctypes.cast(arr8, ctypes.POINTER(ctypes.c_ubyte)) y_ptr = ctypes.cast(arr8, ctypes.POINTER(ctypes.c_ubyte))
uv_ptr = ctypes.cast( uv_ptr = ctypes.cast(
ctypes.byref(arr8, y_size8), ctypes.byref(arr8, y_size8),
@ -631,16 +642,25 @@ else:
sdl2.SDL_UpdateNVTexture( sdl2.SDL_UpdateNVTexture(
texture, None, y_ptr, pitch8, uv_ptr, uv_pitch8, texture, None, y_ptr, pitch8, uv_ptr, uv_pitch8,
) )
else: elif texture:
# BGRA fallback (SW decode path) # BGRA fallback (SW decode path)
pix = ctypes.cast(arr8, ctypes.POINTER(ctypes.c_ubyte)) pix = ctypes.cast(arr8, ctypes.POINTER(ctypes.c_ubyte))
sdl2.SDL_UpdateTexture(texture, None, pix, pitch8) sdl2.SDL_UpdateTexture(texture, None, pix, pitch8)
t_upload = (time.monotonic() - t_up0) * 1e6 t_upload = (time.monotonic() - t_up0) * 1e6
# --- SDL_RenderCopy --- # --- SDL_RenderCopy (letterbox into window) ---
t_r0 = time.monotonic() t_r0 = time.monotonic()
sdl2.SDL_RenderClear(renderer) sdl2.SDL_RenderClear(renderer)
sdl2.SDL_RenderCopy(renderer, texture, None, None) if texture:
# Fit frame into window preserving AR (letterbox).
win_w, win_h = w_actual.value, h_actual.value
scale = min(win_w / w8, win_h / h8) if w8 > 0 and h8 > 0 else 1.0
dw = max(1, int(w8 * scale))
dh = max(1, int(h8 * scale))
dx = (win_w - dw) // 2
dy = (win_h - dh) // 2
dst = sdl2.SDL_Rect(dx, dy, dw, dh)
sdl2.SDL_RenderCopy(renderer, texture, None, dst)
sdl2.SDL_RenderPresent(renderer) sdl2.SDL_RenderPresent(renderer)
t_render = (time.monotonic() - t_r0) * 1e6 t_render = (time.monotonic() - t_r0) * 1e6
@ -656,7 +676,8 @@ else:
pipeline8.set_state(Gst.State.NULL) pipeline8.set_state(Gst.State.NULL)
eos8.set() eos8.set()
sdl2.SDL_DestroyTexture(texture) if texture:
sdl2.SDL_DestroyTexture(texture)
sdl2.SDL_DestroyRenderer(renderer) sdl2.SDL_DestroyRenderer(renderer)
sdl2.SDL_DestroyWindow(window) sdl2.SDL_DestroyWindow(window)
sdl2.SDL_Quit() sdl2.SDL_Quit()

Loading…
Cancel
Save