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>
This commit is contained in:
4grayandClaude Fable 5 authored and 4gray committed 2026-07-15 19:48:33 +02:00
1 parent 26271fc076
commit 1a0c2306a9
6 files changed
+378 -62

No files matched your search

+26 -1
View File
@@ -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
? {
+35
View File
@@ -117,6 +117,41 @@
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || '/opt/homebrew/lib'; process.stdout.write(path.join(dir, 'libmpv.2.dylib'))\")"
]
}
],
[
"OS==\"linux\"",
{
"type": "executable",
"sources": [
"helper/mpv_frame_helper.cpp"
],
"include_dirs": [
"<!(node -p \"process.env.LIBMPV_INCLUDE_DIR || '/usr/include'\")",
"helper"
],
"cflags_cc!": [
"-fno-exceptions"
],
"cflags_cc": [
"-std=c++17",
"-fexceptions"
],
"cflags": [
"-pthread"
],
"ldflags": [
"-pthread",
"-Wl,-rpath,'$$ORIGIN/lib'",
"-Wl,-rpath,<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")"
],
"libraries": [
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
"-lmpv",
"-lEGL",
"-lOpenGL",
"-lgbm"
]
}
]
]
}
@@ -0,0 +1,285 @@
/*
* Headless GL context for the frame-copy helper, one implementation per
* platform behind the same tiny GlContext surface.
*
* macOS: accelerated CGL 3.2 core context; GL entry points for mpv come
* from the OpenGL framework via dlsym.
*
* Linux: EGL display picked in order — Mesa's surfaceless platform (no
* display server needed), the session's default display, then a GBM render
* node — with a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
* fallback for drivers without EGL_KHR_surfaceless_context). The helper's
* own GL calls resolve at link time through libOpenGL (glvnd); mpv resolves
* its entry points via eglGetProcAddress.
*
* Threading contract: create() runs on the main thread and must leave the
* context unbound; the render thread calls makeCurrent() once and owns the
* context until destroy().
*/
#pragma once
#include <string>
#if defined(__APPLE__)
#define GL_SILENCE_DEPRECATION
#include <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include <dlfcn.h>
#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 <EGL/egl.h>
#include <EGL/eglext.h>
#include <GL/gl.h>
#include <GL/glext.h>
#include <fcntl.h>
#include <gbm.h>
#include <unistd.h>
#include <cstdio>
#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<void*>(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
@@ -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 <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include <mpv/client.h>
#include <mpv/render_gl.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
@@ -30,12 +26,13 @@
#include <mutex>
#include <string>
#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<int> 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<char*>(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();
}
@@ -11,6 +11,7 @@
#pragma once
#include <stdint.h>
#include <time.h>
#ifdef __cplusplus
#include <atomic>
@@ -20,6 +21,16 @@ typedef std::atomic<uint64_t> 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 {
@@ -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 <node_api.h>
#ifdef __APPLE__
#if defined(__APPLE__) || defined(__linux__)
#include <fcntl.h>
#include <stdatomic.h>
@@ -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;