diff --git a/CLAUDE.md b/CLAUDE.md index 1f1914a..3db55f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,60 @@ Bug classes that have each bitten this codebase at least once (2026-07-13 audit campaign, v10.2→v10.9). Before writing code that touches these areas, check the rule. Before claiming one of these is fixed, run the probe. +## Platforms + +- **macOS has no `--wid` embed.** mpv's Swift backend doesn't support it + (mpv-examples#29, maintainer-confirmed; IPTVnator hit the black-surface + failure). macOS cells render via the libmpv render API (`vo=libmpv`) into + `macembed.MpvGLWidget` (QOpenGLWidget) — the platform opts in + `constants.mpv_opts_for_platform()` pick this; never pass `wid=` on darwin. +- Render API threading (render.h): the update callback fires on an mpv + thread — bare signal emit only; all `mpv_render_*` calls run on the GUI + thread with the widget's GL context current; free the render context + BEFORE `mpv.terminate()` (`VideoCell._destroy_mpv` order is load-bearing). +- Never let `mpv_render_context_render` block the GUI thread on the audio + clock: `block_for_target_time=False` + `video-timing-offset=0` on darwin. +- The `& 0xFFFFFFFF` HWND mask is Windows-only (`constants.native_wid`) — + it would truncate the 64-bit NSView pointer on macOS. +- libmpv discovery on macOS: `DYLD_FALLBACK_LIBRARY_PATH` must include the + Homebrew prefix lib dir BEFORE python starts (launch.sh) — setting it + from inside Python is a no-op. +- **libmpv refuses non-C LC_NUMERIC.** `mpv_create()` returns NULL under + e.g. en_US.UTF-8 (mpv player/main.c check_locale) and python-mpv then + SEGFAULTS in mpv_set_option — the crash surfaces at first MPV(), not at + import. POSIX Python sets LC_ALL from the env at startup; the Windows + CRT keeps LC_NUMERIC=C, which is why Windows never hit it. app.py forces + `setlocale(LC_NUMERIC, "C")` early; launch.sh exports it too. If a + libmpv embed segfaults at 0x48 (NULL handle), check the locale first. +- ru_maxrss units differ: bytes on macOS, KiB on Linux (soak sampler). +- PyQt6 `QOpenGLContext.getProcAddress` wants bytes/QByteArray, not str — + and ANY exception inside a ctypes callback is swallowed by the FFI, so + libmpv gets a garbage GL function pointer and bus-errors when it calls + it. The macembed `_resolve` callback must be total (try/except → 0). +- Qt **qFatals (SIGABRT) on a cross-thread `QOpenGLWidget.makeCurrent`** — + and the wall's shutdown terminates cells on a ThreadPoolExecutor, so + `MpvGLWidget.release()` must only free synchronously on the widget's own + thread; off-GUI callers queue the free (best-effort at exit). Mid-session + destroys are GUI-thread, so only shutdown ever hits this. +- The whole teardown chain is only as strong as its weakest raise: a + TypeError in `release()` (bad `QTimer.singleShot` overload — PyQt6 has + NO (msec, receiver, slot) form; use a queued pyqtSignal for + cross-thread hops) aborted cell teardown, mpv.terminate never ran, and + the live vo thread fired the update callback into a dying widget → + segfault in `pyqtBoundSignal_emit`. Rules: `release()` NEVER raises; + the mpv update callback is a total function gated by an + `_accepting_frames` flag set False FIRST at release; never store a raw + `signal.emit` as `MpvRenderContext.update_cb`; clear `update_cb = None` + before freeing the context. +- render.h ordering vs the bounded-terminate pool is a structural + conflict: the pool terminates cores while the GUI thread blocks in + `concurrent.futures.wait`, so a free QUEUED from a pool thread to the + GUI thread can never run before terminate → core destroyed with a live + render context → SIGABRT. Resolution: `wall._cleanup` frees ALL render + contexts synchronously on the GUI thread (native windows still alive) + BEFORE submitting cell releases to the pool. Queued-free remains only + as the fallback for hypothetical off-GUI callers. + ## python-mpv API - `m["name"]` reads/writes **options/**`name`, NOT the property. Property-only diff --git a/README.md b/README.md index 95a79a5..5419da7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,41 @@ hardware-accelerated video cells powered by libmpv. - **G-Sync isolation** — per-app NVIDIA Profile Inspector profile disables VRR for Hyperwall only, avoiding mixed-FPS jitter +## Quick Start (macOS — Apple Silicon / Intel, experimental) + +macOS support uses a different video path: mpv's Swift backend does **not** +support `--wid` window embedding, so each cell renders through the libmpv +render API (`vo=libmpv`) into a QOpenGLWidget — the same architecture as +IINA/IPTVnator — with VideoToolbox hardware decode. + +``` +# 1. Clone +git clone https://github.com/tcconnally/hyperwall.git +cd hyperwall + +# 2. Bootstrap (brew install mpv, creates .venv, installs deps, verifies libmpv) +./bootstrap.sh + +# 3. Configure +cp config.example.ini config.ini # bootstrap does this if missing +open -e config.ini # fill in server_url, username, password + +# 4. Run +./launch.sh +``` + +macOS notes: + +- Requires Homebrew; `brew install mpv` provides `libmpv.dylib`. + `launch.sh` exports `DYLD_FALLBACK_LIBRARY_PATH` so python-mpv finds it + (must be set before Python starts — don't skip launch.sh). +- Multi-monitor fullscreen works best with *System Settings → Desktop & + Dock → Displays have separate Spaces* enabled (default). +- G-Sync isolation and the .exe build are Windows-only; macOS runs script + mode with CoreAudio + VideoToolbox. +- If cells show software decode or black frames, try + `HYPERWALL_HWDEC=videotoolbox-copy ./launch.sh`. + ## Quick Start (Windows) ```powershell @@ -40,11 +75,13 @@ notepad config.ini # fill in server_url, username, password ## Requirements -- Windows 10/11 with PowerShell 7+ +- Windows 10/11 with PowerShell 7+ — or — macOS (Apple Silicon/Intel) with + Homebrew (experimental) - Python 3.12+ -- NVIDIA GPU with driver 551+ (for nvdec hardware decode) +- NVIDIA GPU with driver 551+ (Windows nvdec hardware decode); Apple + Silicon uses VideoToolbox - Emby server on local network -- NVIDIA Profile Inspector (optional — for G-Sync isolation) +- NVIDIA Profile Inspector (optional — for G-Sync isolation, Windows only) ## Keyboard Shortcuts diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..7425944 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# HyperWall — one-shot macOS bootstrap (Apple Silicon + Intel) +# Usage: ./bootstrap.sh +set -euo pipefail +cd "$(dirname "$0")" + +echo "==================== HYPERWALL macOS BOOTSTRAP ====================" +echo "" + +# ── 1. Homebrew ────────────────────────────────────────────────────── +if ! command -v brew >/dev/null 2>&1; then + echo "[FAIL] Homebrew not found. Install from https://brew.sh first:" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + exit 1 +fi +echo "[OK] Homebrew: $(brew --version | head -1)" + +# ── 2. libmpv (the mpv formula ships libmpv.dylib) ────────────────── +BREW_PREFIX="$(brew --prefix)" +if ! ls "$BREW_PREFIX"/lib/libmpv*.dylib >/dev/null 2>&1; then + echo "[*] Installing mpv (provides libmpv)..." + brew install mpv +fi +echo "[OK] libmpv: $(ls "$BREW_PREFIX"/lib/libmpv*.dylib | head -1)" + +# ── 3. Python 3.12+ ────────────────────────────────────────────────── +PY="python3" +if ! $PY -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' 2>/dev/null; then + echo "[*] python3 missing or < 3.12 — installing python@3.13..." + brew install python@3.13 + PY="$BREW_PREFIX/bin/python3.13" +fi +echo "[OK] Python: $($PY --version 2>&1)" + +# ── 4. venv + Python deps ──────────────────────────────────────────── +if [ ! -d .venv ]; then + echo "[*] Creating .venv..." + "$PY" -m venv .venv +fi +./.venv/bin/pip install --quiet --upgrade pip +./.venv/bin/pip install --quiet python-mpv pyqt6 requests flask +echo "[OK] Python deps installed (python-mpv, pyqt6, requests, flask)" + +# ── 5. config ──────────────────────────────────────────────────────── +if [ ! -f config.ini ]; then + cp config.example.ini config.ini + echo "[*] config.ini created — edit server_url / username / password." +else + echo "[OK] config.ini present" +fi + +# ── 6. verify libmpv binding (same env as launch.sh) ───────────────── +export DYLD_FALLBACK_LIBRARY_PATH="$BREW_PREFIX/lib:/usr/local/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" +export LC_NUMERIC=C # libmpv's check_locale rejects non-C LC_NUMERIC +if ./.venv/bin/python -c "import mpv" 2>/tmp/hyperwall_mpv_err; then + echo "[OK] python-mpv bound to libmpv" +else + echo "[FAIL] python-mpv could not load libmpv:" + cat /tmp/hyperwall_mpv_err + echo " Check that 'brew install mpv' succeeded and re-run ./bootstrap.sh" + exit 1 +fi + +# Headless probe: actually create a player (catches locale/ABI failures +# that a bare import can't — mpv_create() returning NULL segfaults +# python-mpv at first use, so verify it HERE, not at first playback). +if ./.venv/bin/python - <<'EOF' 2>/tmp/hyperwall_mpv_err +import mpv +# Creation alone is the probe (mpv_create NULL → python-mpv segfault). +# Don't query properties here: names differ across mpv versions. +m = mpv.MPV(vo="null", vid="no", aid="no", idle="yes") +m.terminate() +print("[OK] mpv player created and terminated cleanly") +EOF +then + : +else + echo "[FAIL] libmpv loaded but mpv_create() failed:" + cat /tmp/hyperwall_mpv_err + exit 1 +fi + +echo "" +echo "==================== DONE ====================" +echo " Configure: edit config.ini (Emby server_url/username/password)" +echo " Launch: ./launch.sh" diff --git a/hyperwall/__init__.py b/hyperwall/__init__.py index c2756c4..f30bd0f 100644 --- a/hyperwall/__init__.py +++ b/hyperwall/__init__.py @@ -12,13 +12,19 @@ # window titles so a version bump touches exactly ONE line (this file). VERSION_SHORT = ".".join(__version__.split(".")[:2]) APP_NAME = "Hyperwall" -PACKAGE_LABEL = "d3d11-native-embed" import os import subprocess import sys from pathlib import Path +# Embed architecture label for the runtime banner — d3d11 wid-embed on +# Windows, libmpv render API (QOpenGLWidget) on macOS. +PACKAGE_LABEL = ( + "videotoolbox-libmpv-render" if sys.platform == "darwin" + else "d3d11-native-embed" +) + def _repo_root() -> Path: return Path(__file__).resolve().parents[1] diff --git a/hyperwall/app.py b/hyperwall/app.py index da12f6b..bd9e395 100644 --- a/hyperwall/app.py +++ b/hyperwall/app.py @@ -173,12 +173,24 @@ def main() -> None: try: import mpv # noqa: F401 except Exception as e: + if sys.platform == "darwin": + hint = ( + "Install libmpv via Homebrew:\n brew install mpv\n\n" + "Then launch via ./launch.sh — it exports\n" + "DYLD_FALLBACK_LIBRARY_PATH so python-mpv finds\n" + "/opt/homebrew/lib/libmpv.dylib on Apple Silicon." + ) + elif os.name == "nt": + hint = ( + f"And place mpv-2.dll next to this script:\n {SCRIPT_DIR}\n\n" + f"Download: https://sourceforge.net/projects/mpv-player-windows/files/libmpv/\n" + f" (shinchiro build — extract libmpv-2.dll, place in script dir)" + ) + else: + hint = "Install libmpv via your distro (mpv-libs / libmpv-dev)." msg = ( f"python-mpv failed to load: {e}\n\n" - f"Install:\n pip install python-mpv\n\n" - f"And place mpv-2.dll next to this script:\n {SCRIPT_DIR}\n\n" - f"Download: https://sourceforge.net/projects/mpv-player-windows/files/libmpv/\n" - f" (shinchiro build — extract libmpv-2.dll, place in script dir)" + f"Install:\n pip install python-mpv\n\n{hint}" ) try: QApplication(sys.argv) @@ -191,6 +203,19 @@ def main() -> None: _setup_logging() logger.info("Runtime: %s", runtime_banner()) + # 3b. libmpv hard-fails mpv_create() (returns NULL → python-mpv then + # segfaults dereferencing it in mpv_set_option) when LC_NUMERIC is not + # "C"/"C.UTF-8" (mpv player/main.c check_locale). CPython calls + # setlocale(LC_ALL, "") at startup on POSIX, so a normal en_US.UTF-8 + # macOS shell kills every libmpv embed at first MPV() — the Windows CRT + # keeps LC_NUMERIC=C, which is why this only bites POSIX. + if os.name != "nt": + import locale as _locale + try: + _locale.setlocale(_locale.LC_NUMERIC, "C") + except _locale.Error as e: + logger.warning("Could not force LC_NUMERIC=C: %s", e) + # 4. Process priority (HIGH) if os.name == "nt" and not os.environ.get("HYPERWALL_NO_LOG_SETUP"): try: @@ -214,6 +239,17 @@ def main() -> None: except Exception as e: logger.warning("Kernel: priority change failed: %s", e) + if sys.platform == "darwin": + # macOS cells render through QOpenGLWidget (macembed.py): default a + # 3.2 core-profile context (resolves to 4.1 on Apple Silicon) before + # any GL context can be created. + from PyQt6.QtGui import QSurfaceFormat + _fmt = QSurfaceFormat() + _fmt.setVersion(3, 2) + _fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile) + _fmt.setDepthBufferSize(0) # 2D video only + QSurfaceFormat.setDefaultFormat(_fmt) + app = QApplication(sys.argv) theme.apply(app) diff --git a/hyperwall/cell.py b/hyperwall/cell.py index 543f395..0db992c 100644 --- a/hyperwall/cell.py +++ b/hyperwall/cell.py @@ -68,6 +68,7 @@ WATCHDOG_INTERVAL_MS, _s, apply_env_overrides, + native_wid, ) from .reliability import ( apply_jitter, @@ -279,12 +280,19 @@ def __init__(self, controller: Any): vbox.setContentsMargins(0, 0, 0, 0) vbox.setSpacing(0) - self.video_frame = QFrame(self) - self.video_frame.setStyleSheet("background: black;") - self.video_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) - self.video_frame.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors, True) - self.video_frame.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True) + # Video surface. macOS: --wid embedding is unsupported by mpv's + # Swift backend, so cells render through the libmpv render API into + # a QOpenGLWidget (macembed.py). Windows: native HWND embed below. + if sys.platform == "darwin": + from .macembed import MpvGLWidget + self.video_frame = MpvGLWidget(self) + else: + self.video_frame = QFrame(self) + self.video_frame.setStyleSheet("background: black;") + self.video_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) + self.video_frame.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors, True) + self.video_frame.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True) vbox.addWidget(self.video_frame, 1) self._build_controls() @@ -370,27 +378,44 @@ def _ensure_mpv(self) -> None: logger.warning("video_frame not visible — deferring mpv creation.") return - # HWND sign-extension fix: mask to 32-bit - wid = int(self.video_frame.winId()) & 0xFFFFFFFF - if wid == 0: - logger.warning("video_frame.winId() == 0 — widget not realized yet.") - return + _opts = self._mpv_opts or apply_env_overrides(MPV_OPTS) + if sys.platform != "darwin": + # HWND sign-extension fix: mask to 32-bit (Windows only — the + # mask would corrupt a 64-bit pointer elsewhere). + wid = native_wid(int(self.video_frame.winId())) + if wid == 0: + logger.warning("video_frame.winId() == 0 — widget not realized yet.") + return # Suppress FFmpeg C-level stdout/stderr during creation _std_saved = (sys.stdout, sys.stderr) _devnull = open(os.devnull, "w") try: sys.stdout = sys.stderr = _devnull - _opts = self._mpv_opts or apply_env_overrides(MPV_OPTS) - m = _mpv.MPV( - wid=str(wid), - log_handler=self._mpv_log, - **_opts, - ) + if sys.platform == "darwin": + # No --wid on macOS (unsupported by mpv's Swift backend): + # vo=libmpv (from the platform opts) renders through the + # MpvGLWidget's GL framebuffer. + m = _mpv.MPV( + log_handler=self._mpv_log, + **_opts, + ) + else: + m = _mpv.MPV( + wid=str(wid), + log_handler=self._mpv_log, + **_opts, + ) finally: sys.stdout, sys.stderr = _std_saved _devnull.close() + if sys.platform == "darwin": + # Render context must exist before the first loadfile hits the + # VO (render.h); attach_mpv creates it now if GL is up, else at + # initializeGL — which precedes the staggered first play(). + self.video_frame.attach_mpv(m) + # Apply initial state try: m["mute"] = self.muted @@ -498,6 +523,10 @@ def _destroy_mpv(self, wait_s: float = 1.5) -> None: return if STATS_ENABLED: self._flush_stats() + if sys.platform == "darwin": + # render.h: the render context must be freed BEFORE the mpv core + # is destroyed, with the GL context current (GUI thread here). + self.video_frame.release() # Silence the handle BEFORE terminate: a wedged teardown gets # abandoned on a daemon thread below, and an abandoned-but-alive # instance that was audible would keep playing sound that no @@ -570,7 +599,11 @@ def _mpv_log(self, level: str, component: str, message: str) -> None: def showEvent(self, event: Any) -> None: super().showEvent(event) - self.video_frame.winId() # force native window creation + if sys.platform != "darwin": + # Windows: force native window creation for the wid embed. + # QOpenGLWidget needs no winId (and native-windowing it would + # change compositing), so skip on macOS. + self.video_frame.winId() if not self._played_anything and self.current_item is None: # Visual feedback while staggered startup loads content. self._show_loading() diff --git a/hyperwall/constants.py b/hyperwall/constants.py index 8c1e384..722178a 100644 --- a/hyperwall/constants.py +++ b/hyperwall/constants.py @@ -145,7 +145,7 @@ def effective_bitrate_budget_mbps(n_cells: int) -> int: CACHE_BUDGET_MB = _int_env("HYPERWALL_CACHE_BUDGET_MB", 8_192, 128, 65_536) # ── MPV Options ────────────────────────────────────────────────────────────── -MPV_OPTS: dict[str, object] = dict( +_MPV_OPTS_BASE: dict[str, object] = dict( vo="gpu-next", gpu_api="d3d11", # d3d11va decodes straight into D3D11 textures that the gpu-next/d3d11 @@ -200,6 +200,59 @@ def effective_bitrate_budget_mbps(n_cells: int) -> int: msg_level="all=warn,cplayer=info,ao=error,ao/wasapi=fatal", ) +# ── Platform adaptation ────────────────────────────────────────────────────── +IS_WINDOWS = os.name == "nt" +IS_MACOS = sys.platform == "darwin" + + +def mpv_opts_for_platform(platform: str | None = None) -> dict[str, object]: + """Return the base MPV options adjusted for the given platform. + + The base opts target Windows + NVIDIA (d3d11 embed). macOS can't use + any of that: mpv's Swift macOS backend does not support --wid window + embedding at all (mpv maintainer, mpv-examples#29; independently + confirmed by IPTVnator — black video surface), so cells render through + the libmpv render API (vo=libmpv) into QOpenGLWidgets — see + macembed.py. Decode goes through VideoToolbox, audio through CoreAudio. + + Pure function of its argument so the platform matrix is testable + headlessly (tests/test_platform.py). + """ + plat = sys.platform if platform is None else platform + out = dict(_MPV_OPTS_BASE) + if plat == "darwin": + out["vo"] = "libmpv" # render API — --wid unsupported on macOS + out.pop("gpu_api", None) # d3d11 is Windows-only + out["hwdec"] = "videotoolbox" # Apple Silicon hardware decode + out["ao"] = "coreaudio,null" + # With the render API the host drives frame swaps from the single + # GUI thread; never let mpv block the render call waiting for the + # audio clock — 8 cells × up to 50ms blocks would serialize into + # wall-wide jank. A/V drift on the one audible cell is acceptable. + out["video_timing_offset"] = 0 + elif plat.startswith("linux"): + out.pop("gpu_api", None) # let mpv auto-select (vulkan/opengl) + out["hwdec"] = "auto-safe" + out.pop("ao", None) # mpv default (pipewire/pulse/alsa) + return out + + +MPV_OPTS: dict[str, object] = mpv_opts_for_platform() + + +def native_wid(win_id: int, platform: str | None = None) -> int: + """winId() → value to hand to mpv's wid option. + + Windows HWNDs need the 32-bit mask (sign-extension fix); elsewhere + winId() is a full 64-bit pointer (NSView*/Window) that the mask would + corrupt. (macOS embeds via the render API instead of wid — this stays + correct for the Windows path and any future X11 path.) + """ + plat = sys.platform if platform is None else platform + if plat.startswith("win"): + return win_id & 0xFFFFFFFF + return win_id + STATS_ENABLED = os.environ.get("HYPERWALL_STATS") == "1" STATS_COUNTER_PROPS = ( diff --git a/hyperwall/macembed.py b/hyperwall/macembed.py new file mode 100644 index 0000000..79ca3b9 --- /dev/null +++ b/hyperwall/macembed.py @@ -0,0 +1,197 @@ +"""Hyperwall — macOS video surface (libmpv render API). + +mpv's Swift macOS backend does NOT support --wid window embedding (mpv +maintainer in mpv-examples#29: "isn't supported by the new swift backend"; +independently confirmed by IPTVnator — audio with a black video surface). +The only supported embed path on macOS is the render API: the cell's mpv +runs vo=libmpv and renders into this QOpenGLWidget's framebuffer. This is +the same architecture IINA and IPTVnator use. + +Threading rules honored here (libmpv render.h + CLAUDE.md observer rules): +- The update callback fires on an mpv thread. It must not call mpv or touch + Qt state — it only performs a bare signal emit, which Qt queues onto the + GUI thread where update() schedules paintGL. +- Every mpv_render_* call happens on the GUI thread with this widget's GL + context current (initializeGL / paintGL / explicit makeCurrent pairs). +- The render context is freed BEFORE the mpv core is terminated + (render.h: freeing after core destruction is undefined behavior). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QOpenGLContext, QPainter +from PyQt6.QtOpenGLWidgets import QOpenGLWidget + +logger = logging.getLogger("HyperWall") + + +class MpvGLWidget(QOpenGLWidget): + """Per-cell video surface backed by mpv's OpenGL render context.""" + + # mpv thread → GUI thread "frame available" hop. Bare emit only. + sig_frame_ready = pyqtSignal() + # pool thread → GUI thread "free the render ctx" hop (queued). + _sig_free = pyqtSignal() + + def __init__(self, parent: Any = None) -> None: + super().__init__(parent) + self._mpv: Any = None # python-mpv MPV (vo=libmpv) + self._ctx: Any = None # mpv.MpvRenderContext + self._gl_ready = False + self._accepting_frames = True # shutdown silences the update cb + self._get_proc_address: Any = None # CFUNCTYPE — must stay alive + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.sig_frame_ready.connect( + self.update, Qt.ConnectionType.QueuedConnection + ) + self._sig_free.connect(self._free_ctx, Qt.ConnectionType.QueuedConnection) + + # ── lifecycle ───────────────────────────────────────────────────── + + def attach_mpv(self, m: Any) -> None: + """Bind an MPV created with vo=libmpv. + + Creates the render context immediately when GL is up, otherwise at + initializeGL — which always runs before the first paint, i.e. well + before the staggered first loadfile reaches the VO (render.h: video + init fails if no render context exists by then). + """ + self._mpv = m + if self._gl_ready and self._ctx is None: + self.makeCurrent() + try: + self._create_render_ctx() + finally: + self.doneCurrent() + + def release(self) -> None: + """Free the render context (before the mpv core is terminated). + + NEVER raises: cell.release() aborts the whole teardown chain (mpv + terminate never runs) if this throws — then the live vo thread + keeps firing the update callback into a dying widget → segfault + in pyqtBoundSignal_emit (shipped once, M5 Air 2026-07-21). + + Qt qFatals (SIGABRT) on a cross-thread makeCurrent, and the wall's + shutdown terminates cells on a ThreadPoolExecutor — so: silence + the callback FIRST (synchronous, any thread), free synchronously + on the widget's own (GUI) thread, queue a best-effort free for + off-GUI callers. If the loop is already dead, process exit + reclaims the context. + """ + try: + self._accepting_frames = False + if self._ctx is not None: + # Stop libmpv from invoking the callback at all. After this, + # no path from the vo thread touches this widget. + try: + self._ctx.update_cb = None + except Exception: + pass + if QThread.currentThread() is self.thread(): + self._free_ctx() + else: + self._sig_free.emit() + except Exception as e: + logger.debug("video frame release raised: %s", e) + + def _free_ctx(self) -> None: + """Free the render context. GUI thread only.""" + if self._ctx is None: + return + self.makeCurrent() + if QOpenGLContext.currentContext() is None: + # Native window already torn down (shutdown) — freeing with no + # current context is UB. Leak it; the process is exiting. + logger.debug("GL gone at release — abandoning render ctx.") + self._ctx = None + return + try: + ctx, self._ctx = self._ctx, None + ctx.free() + except Exception as e: + logger.debug("mpv render ctx free raised: %s", e) + finally: + self.doneCurrent() + + # ── GL plumbing ─────────────────────────────────────────────────── + + def initializeGL(self) -> None: + self._gl_ready = True + if self._mpv is not None and self._ctx is None: + self._create_render_ctx() + + def _create_render_ctx(self) -> None: + import mpv as _mpv + + def _resolve(_ctx: int, name: bytes) -> int: + # NEVER raise in here: an exception inside a ctypes callback is + # swallowed by the FFI, libmpv then calls the garbage pointer it + # got back → bus error (shipped once, M5 Air 2026-07-21). + # PyQt6 getProcAddress wants bytes/QByteArray, NOT str. + try: + glctx = QOpenGLContext.currentContext() + if glctx is None: + return 0 + addr = glctx.getProcAddress(name) + return int(addr) if addr else 0 + except Exception: + return 0 + + # libmpv stores the raw function pointer for the render context's + # lifetime and may resolve lazily — keep the CFUNCTYPE alive on self. + self._get_proc_address = _mpv.MpvGlGetProcAddressFn(_resolve) + self._ctx = _mpv.MpvRenderContext( + self._mpv, + "opengl", + opengl_init_params={"get_proc_address": self._get_proc_address}, + ) + self._accepting_frames = True + self._ctx.update_cb = self._on_mpv_frame + logger.debug("mpv render context created (opengl).") + + def _on_mpv_frame(self) -> None: + """mpv vo thread → frame available. NEVER raise (ctypes callback). + + Do NOT store `self.sig_frame_ready.emit` here directly: during + interpreter/shutdown teardown the callback can fire while the + widget is being destroyed, and pyqtBoundSignal_emit on a dying + object segfaults (crash 303B40DE, thread 'vo'). The flag goes + False synchronously at release() — before anything that can fail. + """ + try: + if self._accepting_frames: + self.sig_frame_ready.emit() + except Exception: + pass + + # ── painting ────────────────────────────────────────────────────── + + def paintGL(self) -> None: + if self._ctx is None: + # No mpv yet (staggered startup) — paint solid black so the + # composited cell matches the Windows background. + p = QPainter(self) + p.fillRect(self.rect(), Qt.GlobalColor.black) + p.end() + return + dpr = self.devicePixelRatioF() + w = max(1, int(self.width() * dpr)) + h = max(1, int(self.height() * dpr)) + try: + self._ctx.render( + opengl_fbo={ + "fbo": int(self.defaultFramebufferObject()), + "w": w, + "h": h, + }, + flip_y=True, # Qt FBO origin is bottom-left + block_for_target_time=False, # never block the GUI thread + ) + self._ctx.report_swap() + except Exception as e: + logger.debug("mpv render raised: %s", e) diff --git a/hyperwall/nvidia.py b/hyperwall/nvidia.py index 642623d..607c8a7 100644 --- a/hyperwall/nvidia.py +++ b/hyperwall/nvidia.py @@ -217,6 +217,8 @@ def maybe_relaunch_in_isolation() -> None: When running as 'python hyperwall.py', relaunch via the bundled exe so the NVIDIA driver matches the per-app G-Sync profile. """ + if not _IS_WINDOWS: + return # no bundled exe / G-Sync profile off Windows if _is_isolated_launch(): return if not os.path.exists(LAUNCHER_EXE): diff --git a/hyperwall/soak.py b/hyperwall/soak.py index 1992b71..19ac28e 100644 --- a/hyperwall/soak.py +++ b/hyperwall/soak.py @@ -27,12 +27,15 @@ from __future__ import annotations import ctypes -import ctypes.wintypes as wt import logging import os import random +import sys import time +if os.name == "nt": + import ctypes.wintypes as wt + from PyQt6.QtCore import QObject, QTimer logger = logging.getLogger("HyperWall") @@ -59,12 +62,31 @@ class _PROCESS_MEMORY_COUNTERS(ctypes.Structure): ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t), - ] + ] if os.name == "nt" else [] def _resource_snapshot() -> dict[str, int]: - """Working set / private bytes / GDI / USER / threads for this process.""" + """Working set / private bytes / GDI / USER / threads for this process. + + GDI/USER counts are Windows-only; POSIX gets RSS from getrusage. + """ out: dict[str, int] = {} + if os.name != "nt": + try: + import resource + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + # ru_maxrss units: KiB on Linux, BYTES on macOS. + out["ws_mb"] = int( + rss // (1024 * 1024) if sys.platform == "darwin" else rss // 1024 + ) + except Exception as e: + logger.debug("SOAK resource snapshot failed: %s", e) + try: + import threading + out["threads"] = threading.active_count() + except Exception: + pass + return out try: k32 = ctypes.windll.kernel32 u32 = ctypes.windll.user32 diff --git a/hyperwall/wall.py b/hyperwall/wall.py index 4afc452..6b77cee 100644 --- a/hyperwall/wall.py +++ b/hyperwall/wall.py @@ -683,6 +683,21 @@ def _cleanup(self) -> None: return self._cleaned_up = True + # darwin: free each cell's mpv render context HERE — synchronously, + # on the GUI thread, while the native windows still exist — BEFORE + # the pool below terminates the mpv cores. render.h: freeing after + # core destruction is UB. The old design queued the free onto the + # GUI thread from the pool, but the GUI thread was blocked waiting + # ON the pool → terminate ran with a live render context → SIGABRT + # at exit (M5 Air 2026-07-21, third exit crash). + import sys as _sys + if _sys.platform == "darwin": + for c in self.cells: + try: + c.video_frame.release() + except Exception as e: + logger.debug("GL pre-release failed: %s", e) + # Hide all windows immediately for w in self.windows: try: diff --git a/launch.sh b/launch.sh new file mode 100755 index 0000000..f765803 --- /dev/null +++ b/launch.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# HyperWall — macOS launcher (script mode; the .exe/G-Sync path is Windows-only) +set -euo pipefail +cd "$(dirname "$0")" + +# python-mpv finds libmpv via ctypes.util.find_library, which consults +# DYLD_FALLBACK_LIBRARY_PATH — but only as it was at process start. +# Homebrew's lib dir (/opt/homebrew/lib on Apple Silicon, /usr/local/lib on +# Intel) is not in dyld's default fallback, so export it BEFORE exec'ing +# python. Setting it later from inside Python would be a no-op. +BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)" +export DYLD_FALLBACK_LIBRARY_PATH="$BREW_PREFIX/lib:/usr/local/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + +# libmpv refuses to create a player under a non-C LC_NUMERIC locale +# (mpv check_locale); Python sets it from the environment at startup. +# app.py also forces it in-process — this is defense-in-depth. +export LC_NUMERIC=C + +PY="./.venv/bin/python" +[ -x "$PY" ] || PY="python3" + +exec "$PY" hyperwall.py "$@" diff --git a/tests/run_all.py b/tests/run_all.py index 7425e1d..5093ef6 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -31,6 +31,7 @@ "test_mute_volume", "test_audit_regressions", "test_freeze_visibility", + "test_platform", ] diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..3e8283b --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,95 @@ +"""Hyperwall — platform adaptation tests (macOS port). + +Pure logic, no PyQt/mpv/Emby: verifies the platform MPV_OPTS matrix and the +wid masking rule that keeps 64-bit NSView pointers intact off Windows. +""" + +from __future__ import annotations + +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + + +def test_01_macos_opts_use_render_api(): + """macOS: vo=libmpv (render API), videotoolbox, coreaudio, no gpu_api.""" + from hyperwall.constants import mpv_opts_for_platform + opts = mpv_opts_for_platform("darwin") + # --wid embedding is unsupported by mpv's Swift macOS backend; cells + # render via the libmpv render API into QOpenGLWidgets (macembed.py). + assert opts["vo"] == "libmpv", opts["vo"] + assert opts["hwdec"] == "videotoolbox", opts["hwdec"] + assert "gpu_api" not in opts, "d3d11 gpu_api leaked into macOS opts" + assert str(opts["ao"]).startswith("coreaudio"), opts["ao"] + # The render call must never block the single GUI thread on the audio + # clock (8 cells x 50ms would serialize into wall-wide jank). + assert opts["video_timing_offset"] == 0 + + +def test_02_windows_opts_unchanged(): + """Windows: the tuned d3d11 path must survive the refactor verbatim.""" + from hyperwall.constants import mpv_opts_for_platform + opts = mpv_opts_for_platform("win32") + assert opts["vo"] == "gpu-next" + assert opts["gpu_api"] == "d3d11" + assert opts["hwdec"] == "d3d11va" + assert str(opts["ao"]).startswith("wasapi") + # HQ downscaling is load-bearing on every platform. + assert opts["dscale"] == "mitchell" + assert opts["correct_downscaling"] == "yes" + + +def test_03_linux_opts_are_sane(): + """Linux: no d3d11 gpu_api, auto-safe hwdec (CI/headless sanity).""" + from hyperwall.constants import mpv_opts_for_platform + opts = mpv_opts_for_platform("linux") + assert "gpu_api" not in opts + assert opts["hwdec"] == "auto-safe" + assert "ao" not in opts + + +def test_04_native_wid_masking(): + """The 32-bit HWND mask must never touch a 64-bit pointer.""" + from hyperwall.constants import native_wid + # Windows HWNDs need the sign-extension mask... + assert native_wid(0xFFFFFFFF80012345, "win32") == 0x80012345 + # ...but the same mask would corrupt a 64-bit NSView*/Window pointer. + assert native_wid(0x00007FF012345678, "darwin") == 0x00007FF012345678 + assert native_wid(0x00007FF012345678, "linux") == 0x00007FF012345678 + + +def test_05_env_overrides_still_win_on_macos(): + """HYPERWALL_HWDEC etc. override the platform defaults (escape hatch).""" + from hyperwall.constants import apply_env_overrides, mpv_opts_for_platform + os.environ["HYPERWALL_HWDEC"] = "videotoolbox-copy" + try: + opts = apply_env_overrides(mpv_opts_for_platform("darwin")) + finally: + del os.environ["HYPERWALL_HWDEC"] + assert opts["hwdec"] == "videotoolbox-copy" + + +def run_all() -> int: + tests = [ + test_01_macos_opts_use_render_api, + test_02_windows_opts_unchanged, + test_03_linux_opts_are_sane, + test_04_native_wid_masking, + test_05_env_overrides_still_win_on_macos, + ] + failed = 0 + for test in tests: + try: + test() + print(f" PASS {test.__name__}") + except Exception as e: + failed += 1 + print(f" FAIL {test.__name__}: {e}") + print(f"\n{len(tests) - failed} passed, {failed} failed out of {len(tests)} tests.") + return failed + + +if __name__ == "__main__": + sys.exit(run_all())