Skip to content

feat(macos): Apple Silicon support via libmpv render API#53

Merged
tcconnally merged 7 commits into
mainfrom
feat/macos-support
Jul 25, 2026
Merged

feat(macos): Apple Silicon support via libmpv render API#53
tcconnally merged 7 commits into
mainfrom
feat/macos-support

Conversation

@tcconnally

Copy link
Copy Markdown
Owner

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 --wid window 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-cell QOpenGLWidget + mpv render context (python-mpv MpvRenderContext). render.h rules honored: update callback = bare queued signal emit; all mpv_render_* on the GUI thread with GL current; ctx freed before mpv.terminate(); block_for_target_time=False + video-timing-offset=0 so N cells never serialize-block the GUI thread on the audio clock.
  • constants.pympv_opts_for_platform(): darwin → vo=libmpv, hwdec=videotoolbox, ao=coreaudio,null, no gpu_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 without wid, render ctx released pre-terminate.
  • app.py — 3.2 core-profile QSurfaceFormat default on darwin; platform-aware libmpv-missing guidance.
  • nvidia.py / soak.py — relaunch/G-Sync no-op off Windows; POSIX soak sampler (ru_maxrss is bytes on macOS, KiB on Linux).
  • bootstrap.sh / launch.sh (new)brew install mpv + venv + deps + libmpv binding verification; launcher exports DYLD_FALLBACK_LIBRARY_PATH before 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.pyall suites green.
  • README macOS quick start; CLAUDE.md gains a Platforms section with the new rules.

Verification status

  • Full headless suite green (logic, opts matrix, imports, POSIX paths)
  • On-device playback on Apple Silicon — pending. The GL render path can only run on a Mac. First-launch checklist: cells render (not black), hwdec-current: videotoolbox in the S overlay, control pills paint above video. Fallback if black cells + audio: HYPERWALL_HWDEC=videotoolbox-copy ./launch.sh.

Run it

./bootstrap.sh   # needs Homebrew; installs mpv, venv, verifies libmpv
# edit config.ini
./launch.sh

Hermes (Perseus Cloud) added 2 commits July 22, 2026 00:20
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
@tcconnally

Copy link
Copy Markdown
Owner Author

First on-device run: root cause found & fixed

Crash signature from the M5 Air: SIGSEGV at 0x48 in mpv_set_option_string, on first MPV() construction (wizard fine → crash at playback start).

Root cause: mpv_create() returns NULL when LC_NUMERIC is not C/C.UTF-8mpv 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() segfault dereferencing the NULL handle (lock_core(ctx) reads ctx->mpctx at offset 0x48). The Windows CRT keeps LC_NUMERIC=C, which is why this never showed on Windows.

Fix (3bf9aa8):

  • app.py forces setlocale(LC_NUMERIC, "C") on POSIX early in main()
  • launch.sh exports LC_NUMERIC=C (defense-in-depth)
  • bootstrap.sh now runs a headless mpv_create probe (vo=null) — a bare import can't catch NULL-create failures; it will fail at bootstrap, not at first playback
  • CLAUDE.md documents the rule + the 0x48 segfault signature

Also confirmed while in there: python-mpv declares mpv_render_context_create with ec_errcheck, so any render-context failure raises a catchable Python exception instead of crashing.

Next verification step on the M5: git pull && ./bootstrap.sh && ./launch.sh — bootstrap's probe should print [OK] mpv player created: ..., then watch cells render + hwdec-current in the S overlay.

Hermes (Perseus Cloud) added 5 commits July 22, 2026 01:26
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.
@tcconnally
tcconnally merged commit 316b147 into main Jul 25, 2026
2 checks passed
@tcconnally
tcconnally deleted the feat/macos-support branch July 25, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant