feat(macos): Apple Silicon support via libmpv render API#53
Conversation
mpv's Swift macOS backend does not support --wid window embedding (mpv-examples#29, maintainer-confirmed), so macOS cells render through the libmpv render API (vo=libmpv) into a per-cell QOpenGLWidget — the same architecture as IINA/IPTVnator. - macembed.py (new): MpvGLWidget — render ctx bound to the widget GL context, update callback as bare queued signal emit, render ctx freed before mpv terminate (render.h ordering), never blocks the GUI thread (block_for_target_time=False + video-timing-offset=0) - constants.py: mpv_opts_for_platform() — darwin gets vo=libmpv, hwdec=videotoolbox, ao=coreaudio, no gpu_api; linux gets auto-safe; Windows byte-identical. native_wid() confines the 32-bit HWND mask to win32 (it would corrupt a 64-bit NSView pointer) - cell.py: darwin branch — MpvGLWidget surface, MPV without wid, render ctx released before terminate - app.py: 3.2 core-profile QSurfaceFormat default on darwin; platform- aware libmpv-missing guidance - nvidia.py: maybe_relaunch_in_isolation() no-ops off Windows - soak.py: POSIX resource sampler (getrusage; ru_maxrss is BYTES on macOS) - bootstrap.sh/launch.sh (new): brew mpv + venv setup; launcher exports DYLD_FALLBACK_LIBRARY_PATH before exec so python-mpv finds libmpv - tests/test_platform.py (new): platform opts matrix + wid masking; full suite green (python tests/run_all.py) Windows behavior unchanged. On-device playback verification on Apple Silicon still pending (render-API path is exercised only on macOS).
…ault mpv_create() returns NULL when LC_NUMERIC is not C/C.UTF-8 (mpv player/main.c check_locale). POSIX CPython calls setlocale(LC_ALL, "") at startup, so a normal en_US.UTF-8 macOS shell makes the first MPV() construction segfault in mpv_set_option dereferencing the NULL handle (SIGSEGV at 0x48, wizard OK → crash at first playback). The Windows CRT keeps LC_NUMERIC=C, which is why this only bites POSIX. - app.py: setlocale(LC_NUMERIC, C) on POSIX right after logging setup - launch.sh: export LC_NUMERIC=C (defense-in-depth) - bootstrap.sh: headless mpv_create probe (vo=null) — a bare import can't catch NULL-create failures; verify at bootstrap, not at first playback - CLAUDE.md: document the locale rule + 0x48 segfault signature
First on-device run: root cause found & fixedCrash signature from the M5 Air: Root cause: Fix (
Also confirmed while in there: python-mpv declares Next verification step on the M5: |
mpv_create itself succeeded (locale fix confirmed working on M5 Air) — the probe then crashed on m.version, which this mpv build doesn't expose. Probe creation+terminate only; property names differ across mpv versions.
PyQt6 QOpenGLContext.getProcAddress requires bytes/QByteArray, not str. The TypeError inside the ctypes callback was swallowed by the FFI, so libmpv received a garbage function pointer and bus-errored on first use (crash at first frame, M5 Air). _resolve is now total (try/except -> 0) and passes the name through as bytes.
wall._cleanup terminates cells on a ThreadPoolExecutor; release() then called QOpenGLWidget.makeCurrent from a pool thread and Qt qFatal'd (SIGABRT on ThreadPoolExecutor-0_0 at every exit). Free synchronously only on the widget's own thread; off-GUI callers queue a best-effort free. Also guard the native-window-already-gone case: freeing a render context with no current GL context is UB, so abandon it (process is exiting anyway). Playback was never affected — mid-session destroys are GUI-thread.
Crash 303B40DE (SIGSEGV, thread 'vo'): QTimer.singleShot(0, receiver, slot) doesn't exist in PyQt6, so release() raised -> cell teardown aborted -> mpv.terminate never ran -> the live vo thread fired the update callback into a widget being destroyed during SystemExit -> pyqtBoundSignal_emit on a dead object. - release() is now total (never raises) so terminate always follows - _accepting_frames flag goes False FIRST, synchronously on any thread, silencing the callback before anything that can fail - update_cb cleared to None before the context is freed - raw signal.emit no longer stored as update_cb; guarded total wrapper - cross-thread free now hops via a queued pyqtSignal instead of the nonexistent QTimer overload
Third exit crash (SIGABRT, no 'Cleanup complete'): the queued GL free from a pool thread can never run while the GUI thread is blocked in cf.wait ON that pool, so mpv.terminate ran with a live render context — exactly the UB render.h forbids. wall._cleanup now frees every cell's render context synchronously on the GUI thread (windows still alive) BEFORE submitting releases to the pool. _destroy_mpv's later release() call is then an idempotent no-op. Note: the crash report attached to the run was stale (incident 303B40DE, 20:47) — macOS re-showed the previous dialog; the new abort never produced a pasted report, this fix is derived from the terminal log.
What
Apple Silicon / Intel macOS support for Hyperwall. Windows path is byte-identical — every change is behind a platform branch.
The constraint that shaped it
mpv's Swift macOS backend does not support
--widwindow embedding (mpv-examples#29, maintainer-confirmed; IPTVnator hit the same black-video-surface failure). The only supported embed on macOS is the libmpv render API (vo=libmpv+ OpenGL FBO) — the IINA/IPTVnator architecture.Changes
hyperwall/macembed.py(new) —MpvGLWidget: per-cellQOpenGLWidget+ mpv render context (python-mpvMpvRenderContext). render.h rules honored: update callback = bare queued signal emit; allmpv_render_*on the GUI thread with GL current; ctx freed beforempv.terminate();block_for_target_time=False+video-timing-offset=0so N cells never serialize-block the GUI thread on the audio clock.constants.py—mpv_opts_for_platform(): darwin →vo=libmpv,hwdec=videotoolbox,ao=coreaudio,null, nogpu_api; linux →auto-safe; Windows unchanged.native_wid()confines the 32-bit HWND mask to win32 (it would truncate a 64-bit NSView pointer).cell.py— darwin branch: GL surface, MPV withoutwid, render ctx released pre-terminate.app.py— 3.2 core-profileQSurfaceFormatdefault on darwin; platform-aware libmpv-missing guidance.nvidia.py/soak.py— relaunch/G-Sync no-op off Windows; POSIX soak sampler (ru_maxrssis bytes on macOS, KiB on Linux).bootstrap.sh/launch.sh(new) —brew install mpv+ venv + deps + libmpv binding verification; launcher exportsDYLD_FALLBACK_LIBRARY_PATHbefore exec (setting it from inside Python is a no-op).tests/test_platform.py(new) — platform opts matrix + wid-masking guards.python tests/run_all.py→ all suites green.Verification status
hwdec-current: videotoolboxin theSoverlay, control pills paint above video. Fallback if black cells + audio:HYPERWALL_HWDEC=videotoolbox-copy ./launch.sh.Run it