Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
86 changes: 86 additions & 0 deletions bootstrap.sh
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 7 additions & 1 deletion hyperwall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
44 changes: 40 additions & 4 deletions hyperwall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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)

Expand Down
Loading
Loading