Files
iptvnator/spikes/mpv-frame-copy/PORTING.md
T
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

12 KiB

Frame-copy engine — Windows/Linux porting handoff

Handoff for future Claude/dev sessions on Windows and Linux machines (they won't have the originating Mac's local session memory — this file is the transfer). Written 2026-07-11 by the macOS session that built the engine; fold into DESIGN.md once both ports land.

State as of 2026-07-15

  • The macOS base shipped through PR #1169 and is now merged into master.
  • The engine works end-to-end on macOS Apple Silicon: Settings toggle → restart → helper process renders mpv offscreen → shm ring → preload pump → WebGL canvas. Verified live with real IPTV + Stalker VOD.
  • Scope decision: macOS = arm64 only (Intel Macs keep the native engine).
  • The Linux port below is DONE in PR #1171 (rebased directly onto the merged #1169 result) — headless EGL helper, portable clock, reader on __linux__, TS gates, i18n, measurements in RESULTS.md. Verified end-to-end in-app on Ubuntu 25.04 (Wayland session) with the xtream mock portal. Dev-build-only on Linux: the helper links system libmpv and electron-after-pack.cjs strips it from packages until milestone 4 (Linux bundled-libmpv runtime) — remove that strip when milestone 4 lands. The Windows implementation lives in the follow-up PR #1175; until that PR lands, master still has no WGL/named-shm helper and the frame-copy flag falls back to the native engine on Windows.
  • PR #1169 credits larsemig's idea (#1154 comment 4932807350). Shared-player controls are reviewed as a separate integration after this platform stack; do not conflate that UI layer with the frame-copy transport ports.

What "porting" means

Only the helper (and a small reader-addon branch) is platform-specific. The stdio protocol, shm layout, TS adapter, main-process service, preload pump, and Angular UI are shared and already shipped.

apps/electron-backend/native/helper/          # state after the Linux port:
├── mpv_frame_helper.cpp     # portable: protocol, mpv session, snapshots
├── frame_helper_io.h        # portable: TSV-in/JSON-out, percent-encoding
├── frame_shm.h              # portable layout + shared CLOCK_MONOTONIC clock
│                            # (POSIX shm calls still need a Windows twin)
├── frame_helper_render.h    # portable: FBO/PBO readback + shm publish
└── frame_helper_gl.h        # PLATFORM SEAM: GlContext — CGL (macOS) and
                             # EGL (Linux); Windows adds its WGL twin HERE
apps/electron-backend/native/src/embedded_mpv_frame_reader.c
                             # real impl on __APPLE__ + __linux__, stub
                             # elsewhere (Windows needs shm-open/clock twins)

Porting Windows = give frame_helper_gl.h a WGL GlContext twin, give the shm create/open (+ frame_shm_now_ns) Windows twins, flip the TS gate in embedded-mpv-frame-copy-platform.util.ts, extend packaging.

Branching & merge strategy

  • Merge order is #1169 → #1171 → #1175. #1169 is already merged; #1171 is based directly on that master, while #1175 remains stacked on the Linux port until #1171 lands.
  • Rewrite only the platform-specific commit range when moving a stacked PR; do not replay the old parent history after its squash merge. Retarget the PR explicitly and keep the parent branch until its child has been rewritten.
  • Keep the stack at most one unmerged level deep. New follow-up work branches from the latest landed platform base on master.
  • Commit incrementally within the port branch; land each platform's measurement rows in RESULTS.md in the same PR as its port.

Per-platform task lists

Linux (DONE 2026-07-11 — see the update in "State" above)

  1. Render backend: headless EGL (EGL_PLATFORM_SURFACELESS_MESA / eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA) with fallback to default-display and GBM candidates) + the same FBO/PBO/readback code. Each candidate is validated through context bind and GL_RENDERER; a hardware renderer wins over an earlier software tier. mpv resolves linked core GL symbols through dlsym(RTLD_DEFAULT) and falls back to eglGetProcAddress for extensions.
  2. shm: POSIX shm_open works as-is. The ONLY blocker in shared code: clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) is macOS-only — replace with a portable now_ns() (clock_gettime(CLOCK_MONOTONIC, ...)) in frame_helper_render.h, frame_shm users, and the reader addon. Keep producer/consumer on the SAME clock.
  3. Reader addon: change #ifdef __APPLE__ to also cover __linux__ (code is already POSIX apart from the clock call).
  4. binding.gyp: helper target gets a linux branch — link system libmpv for dev first (-lmpv); bundled-libmpv runtime is a later packaging task. NOTE: the LINUX ADDON must still not link libmpv (that rule is for in-process only — the helper is a separate process, linking is the whole point and is legal there).
  5. TS gates: isFrameCopyEngineActive/isFrameCopyAvailable in embedded-mpv-native.service.ts (drop the darwin/arm64-only condition for linux), EmbeddedMpvFrameCopyAdapter.isSupported, Settings copy + i18n descriptions currently say "macOS Apple Silicon only".
  6. Big prize: no window embedding → native Wayland just works; later bundle libmpv → no system-mpv-on-PATH requirement → Flatpak/Snap. Update the Linux support matrix in docs/architecture/embedded-mpv-native.md when this lands.

Windows

  1. Render backend: WGL headless — create a hidden message-only window + dummy pixel format, wglCreateContextAttribsARB 3.2 core, then the same FBO/PBO path. MPV_RENDER_API_TYPE_SW is the fallback bring-up if WGL fights (CPU render, still proves the pipeline).
  2. shm: CreateFileMapping(INVALID_HANDLE_VALUE, ...) + MapViewOfFile behind the same FrameShmHeader layout. Name mapping: Local\\impv-... (session-local namespace). Reader addon gets the #ifdef _WIN32 twin. Atomics: std::atomic<uint64_t> fine with MSVC; the C reader can use InterlockedCompareExchange-free plain _Atomic-equivalent via C11 <stdatomic.h> (clang-cl) or volatile+ MemoryBarrier — simplest is compiling the reader as C++ on Windows.
  3. Process control: child.kill('SIGTERM') on Windows is TerminateProcess (no graceful signal) — the quit command + stdin-EOF paths (already implemented) are the graceful route; keep the kill as the hard fallback. stdio pipes work unchanged.
  4. binding.gyp: helper .exe target under OS=="win" linking the existing import lib (LIBMPV_IMPORT_LIB env — see build-embedded-mpv.js Windows path). DLL resolution: the helper exe sits next to lib/ with the mpv DLL — either copy the DLL beside the exe at build time or call SetDllDirectory/AddDllDirectory at startup. Watch the documented import-library-vs-DLL-basename gotcha (embedded-mpv-native.md).
  5. TS gates + audio: same switches as Linux. WASAPI audio comes from mpv directly — nothing to do.
  6. This is the open PERFORMANCE gate: mid-range iGPU laptop numbers decide go/no-go (RESULTS.md has the methodology + reference M1 numbers: 4K60 sustained, ~10 ms produce→upload, zero torn frames).

Both platforms — shared chores

  • validatePackagedEmbeddedMpv in tools/packaging/embedded-mpv-packaging.cjs currently requires frame-copy artifacts on darwin only — extend per platform when artifacts ship. Keep tests host-agnostic (CI runs them on a Linux runner; asserting an empty error list for a darwin dir fails there with "link validation must run on a macOS host" — already fixed once, don't regress).
  • getMainWindowScaleFactor (Electron screen) is cross-platform — no work needed; the helper receives device pixels.
  • Sandbox story: the flag relaxes the BrowserWindow sandbox for the preload reader require. Same trade-off applies on Win/Linux. Revisit-before- default-on candidates are in the architecture doc.

Hard-won gotchas (do not rediscover these)

  1. Preload + tslib: repo tsconfig has target: es2015; ANY construct that emits TS helpers in preload code (async/await, object spread in downlevel positions) with importHelpers: true makes webpack externalize tslib → the sandboxed preload dies with module not found: tslib → window.electron disappears app-wide. apps/electron-backend/tsconfig.app.json now sets importHelpers: false — NEVER revert it. Symptom to recognize: "Unable to load preload script" in renderer console.
  2. V8 memory cage: napi_create_external_arraybuffer over shm aborts in Electron. The reader MUST memcpy into a V8 buffer. Budgeted (~1.2 ms at 4K).
  3. Frame orientation: helper renders with MPV_RENDER_PARAM_FLIP_Y=1 and glReadPixels reads rows bottom-up → the shm buffer is already in texture order. The pump shader samples with UN-flipped uv. Adding a second flip shows upside-down video (bug already made and fixed once).
  4. BGRA fast path: readback as GL_BGRA/GL_UNSIGNED_INT_8_8_8_8_REV, upload as RGBA, swizzle .bgr in the fragment shader. On Windows check whether BGRA readback stays the fast path per driver; measure, don't assume.
  5. Aspect: mpv reports unset video-aspect-override as "-1.000000" → normalize to "no". The helper aspect-fits the FBO to dwidth/dheight inside the viewport (no baked letterbox bars) and bumps a shm generation (<base>-g<N>) on every size change; the pump re-attaches via the FRAME_SOURCE_CHANGED event.
  6. Stale attach race: attach/detach bump a shared epoch in the pump; every await re-checks it. Keep that invariant if touching the pump.
  7. Lifecycle: dispose escalation is quit-command → stdin.end() (helper exits on EOF) → SIGTERM(500 ms) → SIGKILL(2 s). The SERVICE also reaps all sessions on render-process-gone/did-navigate (renderer crash or hard reload never runs Angular teardown — without this, helpers leak). Watch ps | grep iptvnator_mpv_helper during any manual test session.
  8. Stale opt-in: isFrameCopyEngineActive() requires the helper binary on disk; missing helper = silent fallback to native, and the Settings checkbox stays visible while the saved value is true so it can always be cleared.
  9. node-gyp naming: module targets emit <target_name>.node (no product_name needed); the helper uses the "type": "none" default + per-OS "type": "executable" override trick in binding.gyp.
  10. snapshot protocol: helper's snapshot JSON mirrors NativeEmbeddedMpvSessionSnapshot verbatim (volume 0..1, nullable duration/track ids, videoWidth/videoHeight when known). Status semantics are ported from embedded_mpv.mm — END_FILE reason mapping, eof-reached ⇒ ended (keep-open), pause gated on loadedPath, only fatal/load errors flip status. Don't invent new mappings.

Testing recipes

  • Helper standalone (no Electron): (printf 'load\turl=av://lavfi:testsrc2=size=640x360:rate=30\n'; sleep 5; printf 'quit\n') | ./iptvnator_mpv_helper --shm-base /impv-t --width 1280 --height 720 → expect shm generations, snapshot events at 4 Hz, aspect-fit generation after video loads.
  • Reader probe (any Node ≥18): node -e "const r=require('.../embedded_mpv_frame_reader.node'); const i=r.open('/impv-t-g2'); ..." → latestSeq() advancing + pixel min/max spread.
  • In-app: IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 pnpm run serve:backend:embedded-mpv or the Settings toggle (+restart). Second parallel instance for CDP testing: build, then run electron dist/apps/electron-backend/main.js --remote-debugging-port=9223 --user-data-dir=/tmp/x with ELECTRON_IS_DEV=0 for the file:// renderer (dist package.json has no main field — point at main.js explicitly; a separate user-data-dir avoids the Chromium profile singleton).
  • Perf gate: follow RESULTS.md methodology (STATS/LONGRUN lines, present-interval sd/p99/late counters). Reference: M1 Pro tables therein. The spike harness in this directory is macOS-only; for Windows/Linux measure through the real app + helper stderr or port collect-results.sh.

Suggested milestone order

  1. Completed in #1171: Linux helper bring-up (EGL + portable clock) → lavfi smoke → in-app behind flag → measure.
  2. Windows helper bring-up (WGL, named shm, reader twin) → same ladder → iGPU laptop numbers = the decisive open gate.
  3. Packaging: per-platform artifact validation + runtime staging.
  4. Only then: revisit Linux bundled-libmpv + Flatpak/Snap story.