From 6ef8f4722cdeaade902a829ed483ca479d6a824d Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Tue, 24 Mar 2026 12:15:57 +0100 Subject: [PATCH] test: add section 9 HUD overhead benchmark (draw_playback timing) --- tests/test_video_playback_device.py | 240 ++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/tests/test_video_playback_device.py b/tests/test_video_playback_device.py index 1ce7b8e..b5cfdde 100644 --- a/tests/test_video_playback_device.py +++ b/tests/test_video_playback_device.py @@ -744,4 +744,244 @@ else: _warn(f"sdl2 Python bindings not available: {exc}") _warn("Install: conda install -c conda-forge pysdl2") + +# ── 9. HUD overhead benchmark ────────────────────────────────────────────── +# +# Measures draw_playback() cost per frame using a synthetic 640×360 NV12 +# frame so no GStreamer pipeline is needed. Vsync is disabled so phases +# are timed without vsync-wait interference. +# +# Phases: +# upload_us — SDL_UpdateNVTexture (synthetic NV12 frame) +# video_us — SDL_RenderClear + SDL_RenderCopy (letterboxed video rect) +# hud_us — screens.draw_playback() (all TTF_RenderUTF8_Blended calls) +# present_us — SDL_RenderPresent (no vsync → near-zero on HW renderer) +# +# The "hud_us" line is the key number: add it to the section-8 total to get +# the estimated real-app per-frame cost. If hud_us pushes the combined +# total past 41.7 ms the HUD is causing frame drops. + +_section("9. HUD overhead per frame (draw_playback benchmark)") + +if SKIP_SDL: + _warn("Skipped (--nosection8 flag)") +else: + try: + import ctypes + import statistics + import types + + import sdl2 + import sdl2.sdlttf as _ttf9 + + from r36s_dlna_browser.ui import screens as _screens9, theme as _theme9 + + # ── SDL + TTF init (no vsync so phases are timed cleanly) ──────────── + sdl2.SDL_SetHint(b"SDL_VIDEODRIVER", b"kmsdrm,offscreen") + sdl2.SDL_SetHint(b"SDL_AUDIODRIVER", b"alsa,dummy") + sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO) + _ttf9.TTF_Init() + + _win9 = sdl2.SDL_CreateWindow( + b"S9-HUD", + sdl2.SDL_WINDOWPOS_UNDEFINED, sdl2.SDL_WINDOWPOS_UNDEFINED, + 640, 640, + sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP | sdl2.SDL_WINDOW_SHOWN, + ) + # Accelerated renderer, NO PRESENTVSYNC — we want clean per-phase timings. + _ren9 = sdl2.SDL_CreateRenderer(_win9, -1, sdl2.SDL_RENDERER_ACCELERATED) + if not _ren9: + _ren9 = sdl2.SDL_CreateRenderer(_win9, -1, sdl2.SDL_RENDERER_SOFTWARE) + + _ww9 = ctypes.c_int(0); _wh9 = ctypes.c_int(0) + sdl2.SDL_GetWindowSize(_win9, ctypes.byref(_ww9), ctypes.byref(_wh9)) + _layout9 = _theme9.get_layout(_ww9.value, _wh9.value) + print(f" Window: {_ww9.value}×{_wh9.value} " + f"HUD top={_layout9.playback_hud_top}px bottom={_layout9.playback_hud_bottom}px") + + # ── Font (same search order as the app) ────────────────────────────── + _font9 = None + for _fp9 in _theme9.FONT_SEARCH_PATHS: + try: + _f9 = _ttf9.TTF_OpenFont(_fp9.encode(), _layout9.playback_font_size) + if _f9: + _font9 = _f9 + print(f" Font: {_fp9} @ {_layout9.playback_font_size}pt") + break + except Exception: + pass + if not _font9: + _warn("No font found — HUD text will be empty (still measures SDL overhead)") + + # ── Icons (optional — mirrors app icon loading) ────────────────────── + _icons9: dict = {} + try: + import sdl2.sdlimage as _img9 + _img9.IMG_Init(_img9.IMG_INIT_PNG) + _icon_map = [ + ("hud-play", "hud-play"), + ("hud-pause", "hud-pause"), + ("hud-stop", "hud-stop"), + ("hud-seek", "hud-seek"), + ("hud-volume", "hud-volume"), + ("hud-display", "hud-display"), + ] + for _ifname, _ikey in _icon_map: + _ipath = _theme9.ICONS_DIR / f"{_ifname}.png" + if _ipath.exists(): + _isurf = _img9.IMG_Load(str(_ipath).encode()) + if _isurf: + _itex = sdl2.SDL_CreateTextureFromSurface(_ren9, _isurf) + sdl2.SDL_FreeSurface(_isurf) + if _itex: + _icons9[_ikey] = _itex + print(f" Icons loaded: {len(_icons9)} / {len(_icon_map)}") + except ImportError: + _warn("sdl2.sdlimage not available — icons skipped (icons=None)") + + # ── Synthetic 640×360 NV12 frame (black) ──────────────────────────── + _nv12_w, _nv12_h = 640, 360 + _y_size9 = _nv12_w * _nv12_h + _uv_size9 = _y_size9 // 2 + # Y=0 (black luma), UV=0x80 (neutral chroma) → solid black frame. + _nv12_data9 = b'\x00' * _y_size9 + b'\x80' * _uv_size9 + _nv12_buf9 = (ctypes.c_ubyte * len(_nv12_data9)).from_buffer_copy(_nv12_data9) + _y_ptr9 = ctypes.cast(_nv12_buf9, ctypes.POINTER(ctypes.c_ubyte)) + _uv_ptr9 = ctypes.cast(ctypes.byref(_nv12_buf9, _y_size9), + ctypes.POINTER(ctypes.c_ubyte)) + + _tex9 = sdl2.SDL_CreateTexture( + _ren9, + sdl2.SDL_PIXELFORMAT_NV12, + sdl2.SDL_TEXTUREACCESS_STREAMING, + _nv12_w, _nv12_h, + ) + + # Letterbox dst rect (same logic as the app render path). + _sc9 = min(_ww9.value / _nv12_w, _wh9.value / _nv12_h) + _dw9 = max(1, int(_nv12_w * _sc9)); _dh9 = max(1, int(_nv12_h * _sc9)) + _dx9 = (_ww9.value - _dw9) // 2; _dy9 = (_wh9.value - _dh9) // 2 + _dst9 = sdl2.SDL_Rect(_dx9, _dy9, _dw9, _dh9) + print(f" Synthetic frame: {_nv12_w}×{_nv12_h} dst rect: {_dw9}×{_dh9} @ ({_dx9},{_dy9})") + + # ── Mock playback state ────────────────────────────────────────────── + _state9 = types.SimpleNamespace( + playback_hud_visible=True, + playback_paused=False, + playback_duration=3600.0, + playback_position=42.0, + playback_volume=80, + playback_buffer_percent=100, + playback_resolution="1920×1080", + playback_backend="gstreamer", + playback_title="Test Video — A Long Title That May Require Ellipsis Fitting.mkv", + playback_hud_mode=_theme9.PLAYBACK_HUD_PINNED, + ) + + # ── Benchmark loop ─────────────────────────────────────────────────── + WARMUP9 = 30 + FRAMES9 = 300 + _upload9: list[float] = [] + _video9: list[float] = [] + _hud9: list[float] = [] + _pres9: list[float] = [] + + print(f" Running {WARMUP9 + FRAMES9} frames (warmup={WARMUP9}) …") + + for _fn9 in range(WARMUP9 + FRAMES9): + # Advance position so time-text changes each frame (exercises _fit_text). + _state9.playback_position = 42.0 + _fn9 * (1.0 / 24.0) + + # Phase 1 — NV12 texture upload. + _t0 = time.monotonic() + sdl2.SDL_UpdateNVTexture( + _tex9, None, _y_ptr9, _nv12_w, _uv_ptr9, _nv12_w + ) + _t_upload = (time.monotonic() - _t0) * 1e6 + + # Phase 2 — RenderClear + RenderCopy (video frame, no HUD). + _t0 = time.monotonic() + sdl2.SDL_SetRenderDrawColor(_ren9, 0, 0, 0, 255) + sdl2.SDL_RenderClear(_ren9) + sdl2.SDL_RenderCopy(_ren9, _tex9, None, _dst9) + _t_video = (time.monotonic() - _t0) * 1e6 + + # Phase 3 — draw_playback() — all TTF_RenderUTF8_Blended + fill-rects. + _t0 = time.monotonic() + _screens9.draw_playback(_ren9, _font9, _state9, _layout9, _icons9 or None) + _t_hud = (time.monotonic() - _t0) * 1e6 + + # Phase 4 — Present (no vsync → should be near-zero on HW renderer). + _t0 = time.monotonic() + sdl2.SDL_RenderPresent(_ren9) + _t_pres = (time.monotonic() - _t0) * 1e6 + + if _fn9 >= WARMUP9: + _upload9.append(_t_upload) + _video9.append(_t_video) + _hud9.append(_t_hud) + _pres9.append(_t_pres) + + # Drain events. + _ev9 = sdl2.SDL_Event() + while sdl2.SDL_PollEvent(ctypes.byref(_ev9)): + pass + + # Cleanup. + for _itex_v in _icons9.values(): + sdl2.SDL_DestroyTexture(_itex_v) + if _tex9: + sdl2.SDL_DestroyTexture(_tex9) + if _font9: + _ttf9.TTF_CloseFont(_font9) + _ttf9.TTF_Quit() + sdl2.SDL_DestroyRenderer(_ren9) + sdl2.SDL_DestroyWindow(_win9) + sdl2.SDL_Quit() + + # ── Report ──────────────────────────────────────────────────────────── + _budget9 = 1_000_000 / 24 # µs per frame @ 24 fps + + def _stat9(label, samples): + if not samples: + print(f" {label:48s}: no samples") + return + mn = statistics.mean(samples) + mx = max(samples) + p95 = sorted(samples)[int(len(samples) * 0.95)] + print(f" {label:48s}: mean {mn:6.0f} µs p95 {p95:6.0f} µs max {mx:6.0f} µs ({mn/_budget9*100:.1f}%)") + + print() + print(" --- Section 9 HUD Timing Report ---") + print(f" Frames measured (excl warmup) : {len(_hud9)}") + _stat9("SDL_UpdateNVTexture (synthetic NV12)", _upload9) + _stat9("SDL_RenderClear+RenderCopy (video)", _video9) + _stat9("draw_playback() — TTF+fills+icons", _hud9) + _stat9("SDL_RenderPresent (no vsync)", _pres9) + + _hud_mean = statistics.mean(_hud9) if _hud9 else 0 + _total9_mean = (statistics.mean(_upload9) + statistics.mean(_video9) + + _hud_mean + statistics.mean(_pres9)) if _upload9 else 0 + print(f"\n {'TOTAL (upload+video+HUD+present)':48s}: {_total9_mean:.0f} µs " + f"({_total9_mean/_budget9*100:.1f}% of 41.7ms budget — no vsync wait)") + + # Estimate real-app cost: section-8 measured memmove+upload+render ≈ 8550 µs + # that path had no HUD; add hud_mean to get the estimated combined overhead. + _s8_baseline = 8550 # µs, from last known section-8 run + _estimated_full = _s8_baseline + _hud_mean + print(f" {'Estimated S8 + HUD combined':48s}: {_estimated_full:.0f} µs " + f"({_estimated_full/_budget9*100:.1f}% of 41.7ms budget)\n") + + if _hud_mean > 15_000: + _warn(f"HUD mean {_hud_mean:.0f} µs > 15 ms — very likely causing frame drops!") + elif _hud_mean > 8_000: + _warn(f"HUD mean {_hud_mean:.0f} µs — significant overhead; monitor for drops at 24 fps") + elif _hud_mean > 3_000: + _warn(f"HUD mean {_hud_mean:.0f} µs — moderate overhead; combined budget may be tight") + else: + _ok(f"HUD overhead {_hud_mean:.0f} µs — not a bottleneck") + + except ImportError as exc: + _warn(f"sdl2 / sdlttf / screens not available: {exc}") + print()