Files
iptvnator/spikes/mpv-frame-copy
4grayandClaude Fable 5 7d75d989e8 feat(embedded-mpv): Linux port of the frame-copy rendering engine (headless EGL) (#1171)
* feat(embedded-mpv): Linux frame-copy helper via headless EGL

Port the frame-copy engine's native layer to Linux (PORTING.md items 1-4):

- frame_helper_gl.h: platform GlContext abstraction. macOS keeps the CGL
  path (moved verbatim); Linux acquires an EGL display in order
  surfaceless-Mesa -> default display -> GBM render node, binds a 3.2 core
  desktop-GL context surfaceless (1x1 pbuffer fallback), and hands mpv
  eglGetProcAddress. The helper's own GL calls link against glvnd
  libOpenGL, so no display server is required.
- frame_shm.h: portable frame_shm_now_ns() (CLOCK_MONOTONIC) shared by the
  helper and the reader addon, replacing the macOS-only
  clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); producer and consumer stay on
  the same clock.
- embedded_mpv_frame_reader.c: real implementation now also on __linux__
  (the code was already POSIX apart from the clock call).
- binding.gyp: OS==linux executable branch for iptvnator_mpv_helper linking
  system libmpv (-lmpv) + EGL/OpenGL/gbm, with rpaths for $ORIGIN/lib and
  the build-time library dir. The in-process addon still does not link
  libmpv - the ban only binds in-process, the helper is out of process.
- build-embedded-mpv.js: system-dev fallback on Linux (LIBMPV_INCLUDE_DIR
  or /usr/include) so a distro libmpv-dev install builds without staging a
  vendored runtime; a pre-set LINUX_NATIVE_LIBRARY_DIR now wins over the
  vendored lib dir.

Verified on Ubuntu 25.04 / i7-1165G7 (Iris Xe): lavfi smoke per PORTING.md
(idle->loading->playing snapshots at 4 Hz, aspect-fit generation bump
g1 1280x720 -> g2 960x720 for a 4:3 source), reader probe 60 fps at
1080p60 with 0 torn reads, clean quit with no leaked processes or shm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): enable the frame-copy engine gates on Linux

Flip the TypeScript side of the Linux port (PORTING.md item 5). A shared
dependency-free predicate, isFrameCopyPlatformSupported() (linux any-arch,
darwin arm64-only), now backs all four gates so they cannot drift:

- main.ts: the persisted Settings toggle promotes to the env flag on Linux
  too (this runs before window creation and controls the sandbox relax).
- EmbeddedMpvNativeService.isFrameCopyEngineActive/isFrameCopyAvailable.
- EmbeddedMpvFrameCopyAdapter.isSupported.

getSupport() ordering: the frame-copy branch moves above the Linux-only
native-engine prerequisites - the X11/Xwayland display-server check and
the system-mpv-on-PATH probe only bind the --wid native engine, while the
frame-copy helper renders offscreen (headless EGL) and links libmpv
itself. createSession() also skips resolving the native window handle for
frame-copy sessions, which the adapter ignores anyway, so native-Wayland
sessions no longer trip the window-handle assertion.

Settings copy: the i18n frame-copy description now says macOS (Apple
Silicon) and Linux in all 18 languages; stale macOS-only doc comments in
the settings/support interfaces updated alongside.

Tests: platform-gate matrix for the adapter (darwin arm64/x64, linux
x64/arm64, win32) and service specs covering Linux activation under
native Wayland, macOS arm64 staying active, macOS x64 staying native, and
the skipped window handle for frame-copy sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(packaging): CI + package guards for the Linux frame-copy helper

- build-and-make.yaml: install libegl-dev/libgl-dev/libgbm-dev on the
  Linux runner (the helper's EGL backend needs them now that the helper
  target builds on Linux), and verify the built helper exists and DOES
  link libmpv - the inverse of the addon's no-libmpv rule, which still
  holds and stays validated.
- electron-after-pack.cjs: strip iptvnator_mpv_helper from packaged Linux
  apps. It links the build host's system libmpv, which end-user systems
  cannot be assumed to have; the support probe treats the missing helper
  as frame-copy-unavailable (dev-build-only engine until the
  bundled-runtime staging milestone).
- frame_helper_gl.h: log the chosen EGL display tier to stderr (the
  adapter mirrors helper stderr), so bring-up problems on exotic setups
  are diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): document the Linux frame-copy port

- architecture doc: frame-copy section covers Linux (EGL display tiers,
  build deps, package strip), Linux support matrix notes the frame-copy
  exception to the X11 + system-mpv requirements, Linux measured baseline.
- RESULTS.md: Ubuntu 25.04 / i7-1165G7 (Iris Xe) measurement rows via the
  production helper + reader probe; viewport-size claim reproduced.
- PORTING.md: Linux marked done with pointers to what changed; Windows
  remains the open port and its perf gate the open decision.
- CLAUDE.md + tools/embedded-mpv/README.md: platform scope, Linux dev
  build requirements, system-headers fallback, helper strip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(embedded-mpv): commit the Linux frame-copy measurement probe

linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the
production helper, attaches the frame-reader addon to the announced shm
generation, and reports new-frame fps, copy wall time, produce->copy age,
torn reads and pixel spread. Usage documented in RESULTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address multi-agent review findings on the Linux port

Confirmed findings (each verified by 3 adversarial reviewers):

- CI would fail to link the helper: -lOpenGL needs the unversioned glvnd
  libOpenGL.so, shipped only by libopengl-dev, which neither the runner
  images nor the previous apt line provide. Added to the workflow and to
  every documented Linux build-dep list.
- The new 'test -x' dist guard could never pass: webpack's dist asset
  copy drops file modes (helper arrives as 0644). The guard is now
  'test -f'; electron-after-pack.cjs restores the execute bit on packaged
  helpers (also fixes packaged-macOS spawns); the support probe now
  requires X_OK, so a mode-stripped helper reads as frame-copy-unavailable
  and falls back to native instead of failing spawn with EACCES.
- The Settings frame-copy toggle was unreachable in exactly the Linux
  states the port targets: the native-Wayland and missing-system-mpv
  unsupported payloads omitted frameCopyAvailable, and toggle visibility
  derives solely from it. Both returns now advertise availability.

Also from review:

- build-embedded-mpv.js keeps the old graceful-skip contract when the new
  system-dev fallback finds libmpv-dev but the GL/EGL/gbm dev stack is
  missing (previously such machines skipped; a hard electron-build
  failure was a regression).
- createSession derives the window-handle skip from the dispatched addon
  instead of re-evaluating the engine gate, so the two cannot disagree.
- The render thread logs the GL renderer string (surfaceless Mesa can
  silently pick llvmpipe on non-Mesa-primary systems; now diagnosable —
  verified 'Mesa Intel Iris Xe' on this machine).
- Specs pin the new semantics: frameCopyAvailable advertised while native
  is unsupported (Wayland / no mpv), frame-copy supported without a
  system mpv, and the handle-skip test disposes its session through the
  owning adapter.
- Docs: PORTING.md file map reflects the frame_helper_gl.h seam for the
  Windows porter; helper-strip removal correctly gated on milestone 4
  (bundled libmpv), not milestone 3; RESULTS.md preamble notes the
  RAW->MONOTONIC clock change; stale '(macOS)' scope comments updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address Greptile/Codex review comments

- Sandbox gate requires a usable helper (Greptile P1, security): the
  main.ts env promotion now also probes for an executable
  iptvnator_mpv_helper before relaxing the window sandbox — a stale
  opt-in on packaged Linux (helper deliberately stripped) or after a
  cleaned native build no longer costs a sandboxless launch for an
  engine that cannot activate. Helper discovery (addon candidate paths +
  X_OK probe) moved into embedded-mpv-frame-copy-platform.util.ts,
  shared by main.ts and the service; the service keeps thin instance
  wrappers so tests can stub per scenario. New util spec pins the
  platform matrix, candidate resolution, and the execute-bit semantics.
- Stale frame-copy artifacts on skipped builds (Codex P2): cleanOutput()
  now also removes iptvnator_mpv_helper and
  embedded_mpv_frame_reader.node, so a failed/skipped rebuild cannot
  leave a previous helper advertising frame-copy support against a
  runtime the build just declared unavailable.
- Multiarch default lib dir (Greptile P1, partially refuted): -l
  resolution never depended on our -L (the compiler's built-in search
  paths include the Debian/Ubuntu multiarch dir — proven by the green CI
  run linking with a nonexistent -L dir), but the system-dev fallback
  now defaults to /usr/lib/<multiarch-triple> when present so the -L
  flag and the helper's baked rpath point somewhere real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): harden Linux frame-copy port

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 20:42:50 +02:00
..

mpv frame-copy spike

Standalone prototype for the frame-copy unification of embedded MPV (analysis: .plans/2026-07-10-embedded-mpv-frame-copy-unification.md). Instead of compositing a native mpv surface under a transparent Electron window, the helper renders mpv offscreen and copies frames into the web UI:

mpv_helper (links libmpv)                Electron viewer
  decode (hwdec) + offscreen GL FBO        shm_reader.node: memcpy newest
  async PBO readback (3-deep ring)         frame -> ArrayBuffer
  BGRA frames -> shm ring (3 slots)        renderer: texSubImage2D in rAF
  audio -> OS directly                     -> WebGL canvas (BGRA swizzle)
        |                                      ^
        +------ POSIX shm ring buffer ---------+

macOS only for now. No Electron-app integration, no controls — this exists to measure whether the copy pipeline meets the go/no-go gates.

Build and run

Requires Homebrew mpv (libmpv + headers) and Node ≥ 18 on PATH; the Makefile auto-detects the Homebrew prefix (Apple Silicon and Intel) and the Node include dir.

Quick checklist for a fresh machine (e.g. the pending Intel Mac run):

brew install mpv                     # libmpv + headers
git clone <repo> && cd <repo>
pnpm install --frozen-lockfile       # provides the Electron binary for the viewer
cd spikes/mpv-frame-copy
./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60'   # sanity
# then the RESULTS.md scenarios (4K synthetic, 4K HEVC hwdec, 50 fps, HDR)
./run.sh /path/to/video.mkv 3840x2160
./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60'   # synthetic source
HELPER_ARGS='--hwdec videotoolbox --loop' ./run.sh /tmp/clip.mp4 3840x2160

run.sh builds both binaries, starts the helper, then opens the Electron viewer (binary auto-detected from the repo node_modules, override with ELECTRON=). The helper prints producer stats to stderr once per second; the viewer prints STATS lines to stdout every 2 s and shows the same in an on-screen HUD. PIXELPROBE is a one-shot image sanity check (spread > 0 means real frames, not black).

Key design points implemented here:

  • Render at viewport size: --size WxH is the FBO size; mpv scales on the GPU before readback, so a 4K source in a 720p viewport costs 720p.
  • Async PBO readback: glReadPixels goes into a 3-deep PBO ring; the previous frame's PBO is mapped/copied while the new readback is in flight.
  • 3-slot shm ring with per-slot seqlock: the reader takes the newest complete frame and drops stale ones, so latency cannot accumulate.
  • Audio never crosses the boundary: the helper plays it directly.
  • mpv keeps its own frame pacing: the render call blocks for mpv's target time (default block_for_target_time), so the helper's render ms figure includes that pacing wait — it is not GPU cost.
  • The addon must memcpy: Electron's V8 memory cage forbids external ArrayBuffers over shm, so zero-copy into JS is impossible. The measured path is readback -> shm -> renderer ArrayBuffer -> GPU texture upload.

First measurements (2026-07-10, MacBook Pro M1 Pro, 120 Hz display)

Canonical measurement log (per machine, with repro commands): RESULTS.md. Append future runs there — especially the pending Intel Mac / Windows iGPU baselines.

Scenario Producer Viewer new-frame fps copy ms avg/p95 upload ms avg/p95 produce→uploaded age ms avg/p95 torn
1080p60 testsrc2 (sw) 60.0 fps 59.8 0.28 / 0.35 0.29 / 0.40 4.2 / 6.8 0
4K60 testsrc2 (sw) 60.0 fps 60.0 1.17 / 1.35 3.8 / 4.5 11.0 / 12.7 0
4K60 HEVC 25 Mbit (hwdec=videotoolbox) 60.0 fps 59.9 1.2 / 1.6 3.3 / 4.1 9.8 / 11.7 0

Helper-side PBO map+copy at 4K: ~1.0–1.8 ms avg. CPU during 4K60 HEVC: helper ~18 %, Electron renderer ~24 % of one core. The only fps dip observed coincided with the --loop file restart (decoder reinit), not the copy path.

Takeaway so far: on Apple Silicon the copy tax is far below the analysis doc's worst-case budget (33 MB memcpy ≈ 1.2 ms, not 6–8 ms; end-to-end added latency ≈ 10 ms, not 40–60 ms). The remaining open gates are weaker hardware and long-run pacing, not raw throughput on modern Macs.

Gate status (details in RESULTS.md)

Measured on M1 Pro (2026-07-10):

  • ✅ Throughput: 4K60 HEVC hwdec sustained end to end with ~10 ms added latency and single-digit-ms copies.
  • ✅ Frame pacing: viewer STATS now report per-interval stddev/p99/late counts (present iv / src iv, LONGRUN cumulative line every 30 s). 50 fps and 25 fps cadences are clean; residual jitter is 120 Hz rAF grid quantization (≤ one display tick), not lost frames.
  • ✅ HDR: 4K25 HDR10 (PQ/BT.2020) tonemapped to SDR by mpv before readback at full rate with unchanged copy costs.

Still open:

  • Mid-range Windows laptop (iGPU) — the actual risk hardware; needs the Windows helper port first. (Intel Macs: skipped by decision — on macOS the engine targets Apple Silicon only, see RESULTS.md.)
  • End-to-end latency flash test (photodiode/screen-capture method) and audio/video sync offset to calibrate --audio-delay.
  • Battery drain delta vs the native-surface approach.

Known limitations of this spike

  • macOS-only helper (CGL); Windows needs WGL/D3D, Linux EGL — same protocol.
  • latestSeq/copyLatest poll in rAF; no wakeup channel (fine at 60 fps).
  • Viewer canvas redraws every rAF tick even without a new frame.
  • No reconnect handling if the helper restarts (viewer keeps last mapping).