diff --git a/tests/test_video_playback_device.py b/tests/test_video_playback_device.py index 003bc8f..ccb1a31 100644 --- a/tests/test_video_playback_device.py +++ b/tests/test_video_playback_device.py @@ -345,9 +345,11 @@ print() # ── 8. End-to-end SDL rendering benchmark ───────────────────────────────── # # 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 -# 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 # # 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.") else: SDL8_SECONDS = 20 # how long to run - SDL8_SCALE_W = 640 - SDL8_SCALE_H = 480 + SDL8_SCALE_W = 640 # width fed into capsfilter; height derived from source DAR try: import ctypes @@ -387,7 +388,7 @@ else: window = sdl2.SDL_CreateWindow( b"R36S playback test", 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, ) if not window: @@ -411,17 +412,12 @@ else: sdl2.SDL_GetWindowSize(window, ctypes.byref(w_actual), ctypes.byref(h_actual)) print(f" SDL window size: {w_actual.value}×{h_actual.value}") - texture = sdl2.SDL_CreateTexture( - renderer, - sdl2.SDL_PIXELFORMAT_NV12, - sdl2.SDL_TEXTUREACCESS_STREAMING, - SDL8_SCALE_W, SDL8_SCALE_H, - ) - if not texture: - _fail(f"SDL_CreateTexture NV12: {sdl2.SDL_GetError().decode()}") - raise RuntimeError("SDL texture") + # Texture is created lazily on the first frame so dimensions match + # the actual GStreamer output (height is AR-derived, not fixed). + texture = None + texture_size = (0, 0) # (w, h) of the current 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) ───────────────────── gi.require_version("GstVideo", "1.0") @@ -454,6 +450,8 @@ else: pass # 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 if _hw_active: scale8 = Gst.ElementFactory.make("videoscale", "vs8") @@ -461,7 +459,7 @@ else: if scale8 and cfilt8: scale8.set_property("method", 0) # nearest-neighbour 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.add(scale8); bin8.add(cfilt8); bin8.add(appsink8) scale8.link(cfilt8); cfilt8.link(appsink8) @@ -470,7 +468,7 @@ else: gp.set_active(True) bin8.add_pad(gp) 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: appsink8.set_property("caps", Gst.Caps.from_string( "video/x-raw,format=NV12;video/x-raw,format=BGRA")) @@ -620,9 +618,22 @@ else: fs.dirty = False 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 --- 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)) uv_ptr = ctypes.cast( ctypes.byref(arr8, y_size8), @@ -631,16 +642,25 @@ else: sdl2.SDL_UpdateNVTexture( texture, None, y_ptr, pitch8, uv_ptr, uv_pitch8, ) - else: + elif texture: # BGRA fallback (SW decode path) pix = ctypes.cast(arr8, ctypes.POINTER(ctypes.c_ubyte)) sdl2.SDL_UpdateTexture(texture, None, pix, pitch8) t_upload = (time.monotonic() - t_up0) * 1e6 - # --- SDL_RenderCopy --- + # --- SDL_RenderCopy (letterbox into window) --- t_r0 = time.monotonic() 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) t_render = (time.monotonic() - t_r0) * 1e6 @@ -656,7 +676,8 @@ else: pipeline8.set_state(Gst.State.NULL) eos8.set() - sdl2.SDL_DestroyTexture(texture) + if texture: + sdl2.SDL_DestroyTexture(texture) sdl2.SDL_DestroyRenderer(renderer) sdl2.SDL_DestroyWindow(window) sdl2.SDL_Quit()