From 1a0c2306a916474da2a1657d45f622bd0b65878c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:25:35 +0200 Subject: [PATCH] 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 --- apps/electron-backend/build-embedded-mpv.js | 27 +- apps/electron-backend/native/binding.gyp | 35 +++ .../native/helper/frame_helper_gl.h | 285 ++++++++++++++++++ .../native/helper/frame_helper_render.h | 63 +--- .../native/helper/frame_shm.h | 13 +- .../native/src/embedded_mpv_frame_reader.c | 17 +- 6 files changed, 378 insertions(+), 62 deletions(-) create mode 100644 apps/electron-backend/native/helper/frame_helper_gl.h diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index ceeac4a70..4788329d4 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -223,6 +223,28 @@ function resolveRuntime() { }; } + if (targetPlatform === 'linux') { + // Dev-first Linux flow (frame-copy helper links system libmpv): a + // distro libmpv-dev install is a full runtime — no staging needed. + // LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system + // paths for machines with a local (non-root) libmpv prefix. + const systemIncludeDir = + process.env.LIBMPV_INCLUDE_DIR || '/usr/include'; + if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) { + return { + origin: 'system-dev', + includeDir: systemIncludeDir, + libDir: process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib', + binDir: undefined, + manifest: { + warning: + 'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.', + }, + windowsImportLib: null, + }; + } + } + if (targetPlatform === 'darwin' && homebrewFallbackEnabled) { const homebrewLibMpv = findLibMpv(homebrewLibDir); const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h'); @@ -454,7 +476,10 @@ function main() { npm_config_update_binary: 'false', LIBMPV_INCLUDE_DIR: runtime.includeDir, ...(targetPlatform === 'linux' - ? { LINUX_NATIVE_LIBRARY_DIR: runtime.libDir } + ? { + LINUX_NATIVE_LIBRARY_DIR: + process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir, + } : { LIBMPV_LIBRARY_DIR: outputLibDir }), ...(runtime.windowsImportLib ? { diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index edfc5ff81..ca494f3a1 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -117,6 +117,41 @@ " + +#if defined(__APPLE__) + +#define GL_SILENCE_DEPRECATION +#include +#include + +#include + +#elif defined(__linux__) + +/* Keep eglplatform.h away from Xlib types: the helper never uses native + * window/display handles, so X11 headers are an unnecessary build dep. */ +#define EGL_NO_X11 1 +#define MESA_EGL_NO_X11_HEADERS 1 +#define GL_GLEXT_PROTOTYPES 1 +#include +#include +#include +#include + +#include +#include +#include + +#include + +#ifndef EGL_PLATFORM_SURFACELESS_MESA +#define EGL_PLATFORM_SURFACELESS_MESA 0x31DD +#endif +#ifndef EGL_PLATFORM_GBM_KHR +#define EGL_PLATFORM_GBM_KHR 0x31D7 +#endif + +#endif + +namespace frame_helper { + +/* Matches mpv_opengl_init_params.get_proc_address. */ +using GlGetProcAddressFn = void* (*)(void* ctx, const char* name); + +#if defined(__APPLE__) + +inline void* glDlsymGetProcAddress(void* ctx, const char* name) { + return dlsym(ctx, name); +} + +class GlContext { +public: + bool create(std::string& errorOut) { + glDylib_ = dlopen( + "/System/Library/Frameworks/OpenGL.framework/Versions/Current/" + "OpenGL", + RTLD_LAZY | RTLD_LOCAL); + if (!glDylib_) { + errorOut = "failed to open the OpenGL framework"; + return false; + } + + CGLPixelFormatAttribute attrs[] = { + kCGLPFAOpenGLProfile, + (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, + kCGLPFAAccelerated, + kCGLPFAColorSize, (CGLPixelFormatAttribute)24, + kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8, + (CGLPixelFormatAttribute)0, + }; + CGLPixelFormatObj pixelFormat = nullptr; + GLint matched = 0; + if (CGLChoosePixelFormat(attrs, &pixelFormat, &matched) != + kCGLNoError || + !pixelFormat) { + errorOut = "no accelerated CGL pixel format"; + return false; + } + const CGLError contextError = + CGLCreateContext(pixelFormat, nullptr, &cgl_); + CGLDestroyPixelFormat(pixelFormat); + if (contextError != kCGLNoError || !cgl_) { + errorOut = "failed to create a headless CGL context"; + return false; + } + return true; + } + + void makeCurrent() { CGLSetCurrentContext(cgl_); } + + void destroy() { + CGLSetCurrentContext(nullptr); + if (cgl_) CGLDestroyContext(cgl_); + cgl_ = nullptr; + /* glDylib_ stays mapped: mpv may still resolve symbols during its + * own teardown, and the process exits right after anyway. */ + } + + GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; } + void* procLoaderCtx() const { return glDylib_; } + +private: + CGLContextObj cgl_ = nullptr; + void* glDylib_ = nullptr; +}; + +#elif defined(__linux__) + +inline void* eglWrapGetProcAddress(void* /*ctx*/, const char* name) { + return reinterpret_cast(eglGetProcAddress(name)); +} + +class GlContext { +public: + bool create(std::string& errorOut) { + if (!acquireDisplay()) { + errorOut = "no usable EGL display (surfaceless/default/GBM)"; + return false; + } + if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) { + errorOut = "EGL implementation lacks desktop OpenGL support"; + return false; + } + EGLConfig config = nullptr; + if (!chooseConfig(EGL_PBUFFER_BIT, &config) && + !chooseConfig(0, &config)) { + errorOut = "no usable EGLConfig for desktop OpenGL"; + return false; + } + const EGLint contextAttrs[] = { + EGL_CONTEXT_MAJOR_VERSION, 3, + EGL_CONTEXT_MINOR_VERSION, 2, + EGL_CONTEXT_OPENGL_PROFILE_MASK, + EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, + EGL_NONE, + }; + context_ = + eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttrs); + if (context_ == EGL_NO_CONTEXT) { + errorOut = "failed to create a 3.2 core EGL context"; + return false; + } + /* Probe surfaceless binding here (main thread) so the render thread + * can just makeCurrent(); fall back to a 1x1 pbuffer. Unbind before + * returning — see the threading contract above. */ + if (eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, + context_) != EGL_TRUE) { + const EGLint pbufferAttrs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, + EGL_NONE}; + surface_ = eglCreatePbufferSurface(display_, config, pbufferAttrs); + if (surface_ == EGL_NO_SURFACE || + eglMakeCurrent(display_, surface_, surface_, context_) != + EGL_TRUE) { + errorOut = + "eglMakeCurrent failed (no surfaceless context or " + "pbuffer)"; + return false; + } + } + eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + return true; + } + + void makeCurrent() { + eglMakeCurrent(display_, surface_, surface_, context_); + } + + void destroy() { + if (display_ != EGL_NO_DISPLAY) { + eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); + if (surface_ != EGL_NO_SURFACE) + eglDestroySurface(display_, surface_); + if (context_ != EGL_NO_CONTEXT) + eglDestroyContext(display_, context_); + eglTerminate(display_); + } + surface_ = EGL_NO_SURFACE; + context_ = EGL_NO_CONTEXT; + display_ = EGL_NO_DISPLAY; + if (gbmDevice_) { + gbm_device_destroy(gbmDevice_); + gbmDevice_ = nullptr; + } + if (gbmFd_ >= 0) { + close(gbmFd_); + gbmFd_ = -1; + } + } + + GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; } + void* procLoaderCtx() const { return nullptr; } + +private: + bool acquireDisplay() { + display_ = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, + nullptr, nullptr); + if (initDisplay(display_)) return true; + + display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (initDisplay(display_)) return true; + + display_ = EGL_NO_DISPLAY; + if (openGbmDevice()) { + display_ = eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, gbmDevice_, + nullptr); + if (initDisplay(display_)) return true; + } + display_ = EGL_NO_DISPLAY; + return false; + } + + static bool initDisplay(EGLDisplay display) { + if (display == EGL_NO_DISPLAY) return false; + EGLint major = 0; + EGLint minor = 0; + return eglInitialize(display, &major, &minor) == EGL_TRUE; + } + + bool openGbmDevice() { + for (int node = 128; node <= 131; node++) { + char devicePath[32]; + std::snprintf(devicePath, sizeof(devicePath), + "/dev/dri/renderD%d", node); + const int fd = open(devicePath, O_RDWR | O_CLOEXEC); + if (fd < 0) continue; + struct gbm_device* device = gbm_create_device(fd); + if (!device) { + close(fd); + continue; + } + gbmFd_ = fd; + gbmDevice_ = device; + return true; + } + return false; + } + + bool chooseConfig(EGLint surfaceType, EGLConfig* out) { + const EGLint attrs[] = { + EGL_SURFACE_TYPE, surfaceType, + EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE, + }; + EGLint matched = 0; + return eglChooseConfig(display_, attrs, out, 1, &matched) == + EGL_TRUE && + matched > 0; + } + + EGLDisplay display_ = EGL_NO_DISPLAY; + EGLContext context_ = EGL_NO_CONTEXT; + EGLSurface surface_ = EGL_NO_SURFACE; + struct gbm_device* gbmDevice_ = nullptr; + int gbmFd_ = -1; +}; + +#else +#error "frame_helper_gl.h has no GL context implementation for this platform" +#endif + +} // namespace frame_helper diff --git a/apps/electron-backend/native/helper/frame_helper_render.h b/apps/electron-backend/native/helper/frame_helper_render.h index 56a93c47b..872314e91 100644 --- a/apps/electron-backend/native/helper/frame_helper_render.h +++ b/apps/electron-backend/native/helper/frame_helper_render.h @@ -1,23 +1,19 @@ /* - * Offscreen render + readback pipeline for the frame-copy helper (macOS). + * Offscreen render + readback pipeline for the frame-copy helper. * - * Headless CGL context, mpv render API into an FBO sized to the viewport, - * async PBO readback ring, BGRA frames published into the FrameShm ring. - * Validated in spikes/mpv-frame-copy (4K60 sustained on M1 Pro). + * Headless GL context (per-platform, see frame_helper_gl.h), mpv render API + * into an FBO sized to the viewport, async PBO readback ring, BGRA frames + * published into the FrameShm ring. Validated in spikes/mpv-frame-copy + * (4K60 sustained on M1 Pro). * * Threading: everything here runs on the render thread except * requestResize()/stop(), which only touch atomics/mutex-guarded state. */ #pragma once -#define GL_SILENCE_DEPRECATION -#include -#include - #include #include -#include #include #include #include @@ -30,12 +26,13 @@ #include #include +#include "frame_helper_gl.h" #include "frame_helper_io.h" #include "frame_shm.h" namespace frame_helper { -inline uint64_t nowNs() { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); } +inline uint64_t nowNs() { return frame_shm_now_ns(); } struct ShmRing { FrameShmHeader* header = nullptr; @@ -129,9 +126,8 @@ private: void renderFrame(); mpv_handle* mpv_ = nullptr; - CGLContextObj cgl_ = nullptr; + GlContext gl_; mpv_render_context* renderContext_ = nullptr; - void* glDylib_ = nullptr; std::string shmBaseName_; ShmRing ring_; @@ -155,10 +151,6 @@ private: std::atomic initState_{0}; }; -inline void* frameHelperGetProcAddress(void* ctx, const char* name) { - return dlsym(ctx, name); -} - inline bool RenderPipeline::start(mpv_handle* mpv, const std::string& shmBaseName, int width, int height, std::string& errorOut) { @@ -166,48 +158,18 @@ inline bool RenderPipeline::start(mpv_handle* mpv, shmBaseName_ = shmBaseName; width_ = width; height_ = height; - - glDylib_ = dlopen( - "/System/Library/Frameworks/OpenGL.framework/Versions/Current/OpenGL", - RTLD_LAZY | RTLD_LOCAL); - if (!glDylib_) { - errorOut = "failed to open the OpenGL framework"; - return false; - } - - CGLPixelFormatAttribute attrs[] = { - kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, - kCGLPFAAccelerated, - kCGLPFAColorSize, (CGLPixelFormatAttribute)24, - kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8, - (CGLPixelFormatAttribute)0, - }; - CGLPixelFormatObj pixelFormat = nullptr; - GLint matched = 0; - if (CGLChoosePixelFormat(attrs, &pixelFormat, &matched) != kCGLNoError || - !pixelFormat) { - errorOut = "no accelerated CGL pixel format"; - return false; - } - const CGLError contextError = - CGLCreateContext(pixelFormat, nullptr, &cgl_); - CGLDestroyPixelFormat(pixelFormat); - if (contextError != kCGLNoError || !cgl_) { - errorOut = "failed to create a headless CGL context"; - return false; - } - return true; + return gl_.create(errorOut); } inline bool RenderPipeline::setupGl(std::string& errorOut) { - CGLSetCurrentContext(cgl_); + gl_.makeCurrent(); if (!rebuildTargets(width_, height_)) { errorOut = "framebuffer setup failed"; return false; } - mpv_opengl_init_params glInit = {frameHelperGetProcAddress, glDylib_}; + mpv_opengl_init_params glInit = {gl_.procLoader(), gl_.procLoaderCtx()}; mpv_render_param params[] = { {MPV_RENDER_PARAM_API_TYPE, const_cast(MPV_RENDER_API_TYPE_OPENGL)}, @@ -399,8 +361,7 @@ inline void RenderPipeline::runLoop() { if (texture_) glDeleteTextures(1, &texture_); if (fbo_) glDeleteFramebuffers(1, &fbo_); if (pbos_[0]) glDeleteBuffers(FRAME_SHM_RING_SLOTS, pbos_); - CGLSetCurrentContext(nullptr); - if (cgl_) CGLDestroyContext(cgl_); + gl_.destroy(); ring_.destroy(); } diff --git a/apps/electron-backend/native/helper/frame_shm.h b/apps/electron-backend/native/helper/frame_shm.h index d77f7c7f9..077feba2b 100644 --- a/apps/electron-backend/native/helper/frame_shm.h +++ b/apps/electron-backend/native/helper/frame_shm.h @@ -11,6 +11,7 @@ #pragma once #include +#include #ifdef __cplusplus #include @@ -20,6 +21,16 @@ typedef std::atomic frame_shm_atomic_u64; typedef _Atomic uint64_t frame_shm_atomic_u64; #endif +/* Monotonic clock for produce_time_ns/heartbeat_ns. Producer (helper) and + * consumer (reader addon) MUST use this same clock so age math stays valid. + * CLOCK_MONOTONIC is portable across macOS (10.12+) and Linux; the Windows + * port will need a QueryPerformanceCounter shim here. */ +static inline uint64_t frame_shm_now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + #define FRAME_SHM_MAGIC 0x564d5046u /* 'FPMV' */ #define FRAME_SHM_VERSION 1u #define FRAME_SHM_RING_SLOTS 3u @@ -27,7 +38,7 @@ typedef _Atomic uint64_t frame_shm_atomic_u64; typedef struct { frame_shm_atomic_u64 seq; /* 0 while the slot is being (re)written */ - uint64_t produce_time_ns; /* CLOCK_MONOTONIC_RAW after copy completes */ + uint64_t produce_time_ns; /* frame_shm_now_ns() after copy completes */ } FrameShmSlot; typedef struct { diff --git a/apps/electron-backend/native/src/embedded_mpv_frame_reader.c b/apps/electron-backend/native/src/embedded_mpv_frame_reader.c index b9bba106e..f84eeb21e 100644 --- a/apps/electron-backend/native/src/embedded_mpv_frame_reader.c +++ b/apps/electron-backend/native/src/embedded_mpv_frame_reader.c @@ -7,13 +7,13 @@ * memory cage forbids external ArrayBuffers over foreign memory. * * Plain C N-API (no node-addon-api) so it stays ABI-stable and trivial. - * macOS-only for now — other platforms export an empty object; the - * TypeScript side gates on platform before requiring it. + * macOS and Linux (POSIX shm) — other platforms export an empty object; + * the TypeScript side gates on platform before requiring it. */ #define NAPI_VERSION 8 #include -#ifdef __APPLE__ +#if defined(__APPLE__) || defined(__linux__) #include #include @@ -30,9 +30,8 @@ static FrameShmHeader* g_header = NULL; static uint8_t* g_base = NULL; static size_t g_size = 0; -static uint64_t now_ns(void) { - return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); -} +/* Same clock as the helper's produce_time_ns/heartbeat_ns writers. */ +static uint64_t now_ns(void) { return frame_shm_now_ns(); } static void unmap_current(void) { if (g_base) { @@ -205,11 +204,11 @@ static napi_value Init(napi_env env, napi_value exports) { return exports; } -#else /* !__APPLE__ */ +#else /* neither __APPLE__ nor __linux__ */ static napi_value Init(napi_env env, napi_value exports) { - /* Frame-copy is macOS-only for now; loading this module elsewhere - * yields an empty exports object and the TS side treats it as + /* Frame-copy is not ported to this platform yet; loading this module + * here yields an empty exports object and the TS side treats it as * unsupported. */ (void)env; return exports;