feat(embedded-mpv): add Windows frame-copy support (#1175)

Port the embedded mpv frame-copy pipeline to Windows with WGL rendering and named shared memory. Includes packaging validation, platform gates, tests, and architecture documentation.
This commit is contained in:
4gray authored and GitHub committed 2026-07-15 21:27:56 +02:00
1 parent 7d75d989e8
commit 59e08fd2d6
41 files changed
+1284 -157

No files matched your search

+6
View File
@@ -323,6 +323,12 @@ jobs:
;;
win32)
test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/libmpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll || test -f dist/apps/electron-backend/native/lib/libmpv.dll
# Frame-copy engine artifacts ship on Windows. The helper
# resolves the mpv DLL from its own directory, so the DLL
# must sit beside it at the native/ top level too.
test -f dist/apps/electron-backend/native/iptvnator_mpv_helper.exe
test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node
find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q .
;;
linux)
node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }"
+1 -1
View File
@@ -617,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in HTML5 player with HLS.js or Video.js
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session `iptvnator_mpv_helper` process renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux — no window embedding, so native Wayland works and the X11/system-mpv requirements of the native Linux engine do not apply) and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported runtime contains both an executable helper and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packaged apps until bundled-runtime staging lands. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
**VOD/Series Detail Pages (two-state layout)**:
+45
View File
@@ -83,6 +83,23 @@
],
"include_dirs": [
"helper"
],
"conditions": [
[
"OS==\"win\"",
{
"defines": [
"WIN32_LEAN_AND_MEAN",
"NOMINMAX"
],
"msvs_settings": {
"VCCLCompilerTool": {
"CompileAs": 2,
"ExceptionHandling": 1
}
}
}
]
]
},
{
@@ -153,6 +170,34 @@
"-ldl"
]
}
],
[
"OS==\"win\"",
{
"type": "executable",
"sources": [
"helper/mpv_frame_helper.cpp"
],
"include_dirs": [
"<!(node -p \"(process.env.LIBMPV_INCLUDE_DIR || process.cwd()).replace(/\\\\\\\\/g, '/')\")",
"helper"
],
"defines": [
"WIN32_LEAN_AND_MEAN",
"NOMINMAX"
],
"msvs_settings": {
"VCCLCompilerTool": {
"ExceptionHandling": 1
}
},
"libraries": [
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || process.cwd(); const lib = process.env.LIBMPV_IMPORT_LIB || path.join(dir, 'mpv.lib'); process.stdout.write(lib.replace(/\\\\\\\\/g, '/'))\")",
"opengl32.lib",
"gdi32.lib",
"user32.lib"
]
}
]
]
}
@@ -15,6 +15,13 @@
* core symbols from that linked library and falls back to eglGetProcAddress
* for extensions.
*
* Windows: WGL against a hidden 1x1 window (never shown, never pumped —
* offscreen FBO rendering only, no SwapBuffers). A legacy context
* bootstraps wglCreateContextAttribsARB for a 3.2 core context. opengl32
* only exports GL 1.1, so the post-1.1 entry points the render pipeline
* needs are declared here as same-named function pointers and resolved via
* wglGetProcAddress; mpv resolves its own through the same loader.
*
* 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().
@@ -59,6 +66,59 @@
#define EGL_PLATFORM_GBM_KHR 0x31D7
#endif
#elif defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <GL/gl.h>
#include <cstdint>
#include <cstdio>
/* The Windows SDK ships GL 1.1 headers only; everything newer that the
* render pipeline touches is declared below and resolved at runtime. */
#ifndef GL_BGRA
#define GL_BGRA 0x80E1
#endif
#ifndef GL_UNSIGNED_INT_8_8_8_8_REV
#define GL_UNSIGNED_INT_8_8_8_8_REV 0x8367
#endif
#ifndef GL_FRAMEBUFFER
#define GL_FRAMEBUFFER 0x8D40
#endif
#ifndef GL_READ_FRAMEBUFFER
#define GL_READ_FRAMEBUFFER 0x8CA8
#endif
#ifndef GL_COLOR_ATTACHMENT0
#define GL_COLOR_ATTACHMENT0 0x8CE0
#endif
#ifndef GL_FRAMEBUFFER_COMPLETE
#define GL_FRAMEBUFFER_COMPLETE 0x8CD5
#endif
#ifndef GL_PIXEL_PACK_BUFFER
#define GL_PIXEL_PACK_BUFFER 0x88EB
#endif
#ifndef GL_STREAM_READ
#define GL_STREAM_READ 0x88E1
#endif
#ifndef GL_MAP_READ_BIT
#define GL_MAP_READ_BIT 0x0001
#endif
typedef ptrdiff_t GLsizeiptr;
typedef ptrdiff_t GLintptr;
#define WGL_CONTEXT_MAJOR_VERSION_ARB 0x2091
#define WGL_CONTEXT_MINOR_VERSION_ARB 0x2092
#define WGL_CONTEXT_PROFILE_MASK_ARB 0x9126
#define WGL_CONTEXT_CORE_PROFILE_BIT_ARB 0x00000001
#endif
namespace frame_helper {
@@ -512,6 +572,222 @@ private:
std::string renderer_;
};
#elif defined(_WIN32)
/* Post-1.1 GL entry points used by frame_helper_render.h, with their real
* names so the shared render code compiles unchanged. APIENTRY (stdcall)
* must match the driver exports. Single-TU helper; inline for safety. */
#define FRAME_HELPER_WGL_ENTRY_POINTS(X) \
X(void, glGenFramebuffers, (GLsizei n, GLuint* out)) \
X(void, glDeleteFramebuffers, (GLsizei n, const GLuint* ids)) \
X(void, glBindFramebuffer, (GLenum target, GLuint fbo)) \
X(void, glFramebufferTexture2D, \
(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, \
GLint level)) \
X(GLenum, glCheckFramebufferStatus, (GLenum target)) \
X(void, glGenBuffers, (GLsizei n, GLuint* out)) \
X(void, glDeleteBuffers, (GLsizei n, const GLuint* ids)) \
X(void, glBindBuffer, (GLenum target, GLuint buffer)) \
X(void, glBufferData, \
(GLenum target, GLsizeiptr size, const void* data, GLenum usage)) \
X(void*, glMapBufferRange, \
(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access)) \
X(GLboolean, glUnmapBuffer, (GLenum target))
#define FRAME_HELPER_WGL_DECLARE(ret, name, args) \
inline ret(APIENTRY* name) args = nullptr;
FRAME_HELPER_WGL_ENTRY_POINTS(FRAME_HELPER_WGL_DECLARE)
#undef FRAME_HELPER_WGL_DECLARE
typedef HGLRC(WINAPI* FrameHelperWglCreateContextAttribsFn)(HDC, HGLRC,
const int*);
/* wglGetProcAddress covers post-1.1 symbols but returns sentinel values for
* the 1.1 core set, which lives in opengl32.dll instead. */
inline void* wglLoaderGetProcAddress(void* ctx, const char* name) {
PROC proc = wglGetProcAddress(name);
const intptr_t sentinel = reinterpret_cast<intptr_t>(proc);
if (proc && sentinel != 1 && sentinel != 2 && sentinel != 3 &&
sentinel != -1) {
return reinterpret_cast<void*>(proc);
}
return reinterpret_cast<void*>(
GetProcAddress(static_cast<HMODULE>(ctx), name));
}
class GlContext {
public:
bool create(std::string& errorOut) {
opengl32_ = GetModuleHandleA("opengl32.dll");
if (!opengl32_) {
errorOut = "opengl32.dll is not loaded";
return false;
}
WNDCLASSA windowClass = {};
windowClass.style = CS_OWNDC;
windowClass.lpfnWndProc = DefWindowProcA;
windowClass.hInstance = GetModuleHandleA(nullptr);
windowClass.lpszClassName = "iptvnator_mpv_helper_gl";
/* Re-registration fails harmlessly if a previous context leaked the
* class; CreateWindow below is the real gate. */
RegisterClassA(&windowClass);
window_ = CreateWindowExA(0, windowClass.lpszClassName, "", WS_POPUP,
0, 0, 1, 1, nullptr, nullptr,
windowClass.hInstance, nullptr);
if (!window_) {
errorOut = "failed to create the hidden GL window";
return false;
}
dc_ = GetDC(window_);
if (!dc_) {
errorOut = "failed to get a DC for the hidden GL window";
return false;
}
PIXELFORMATDESCRIPTOR descriptor = {};
descriptor.nSize = sizeof(descriptor);
descriptor.nVersion = 1;
descriptor.dwFlags = PFD_SUPPORT_OPENGL | PFD_DRAW_TO_WINDOW;
descriptor.iPixelType = PFD_TYPE_RGBA;
descriptor.cColorBits = 32;
descriptor.cAlphaBits = 8;
descriptor.iLayerType = PFD_MAIN_PLANE;
const int pixelFormat = ChoosePixelFormat(dc_, &descriptor);
if (pixelFormat == 0 ||
SetPixelFormat(dc_, pixelFormat, &descriptor) != TRUE) {
errorOut = "no usable pixel format for the hidden GL window";
return false;
}
HGLRC legacy = wglCreateContext(dc_);
if (!legacy) {
errorOut = "failed to create a legacy WGL context";
return false;
}
if (wglMakeCurrent(dc_, legacy) != TRUE) {
if (wglDeleteContext(legacy) != TRUE) {
std::fprintf(stderr,
"wgl create: failed to delete unbound legacy "
"context\n");
}
errorOut = "failed to bind the legacy WGL context";
return false;
}
const auto createContextAttribs =
reinterpret_cast<FrameHelperWglCreateContextAttribsFn>(
wglLoaderGetProcAddress(opengl32_,
"wglCreateContextAttribsARB"));
if (createContextAttribs) {
const int attribs[] = {
WGL_CONTEXT_MAJOR_VERSION_ARB, 3,
WGL_CONTEXT_MINOR_VERSION_ARB, 2,
WGL_CONTEXT_PROFILE_MASK_ARB, WGL_CONTEXT_CORE_PROFILE_BIT_ARB,
0,
};
HGLRC core = createContextAttribs(dc_, nullptr, attribs);
if (core) {
if (wglMakeCurrent(dc_, core) != TRUE) {
if (wglDeleteContext(core) != TRUE) {
std::fprintf(
stderr,
"wgl create: failed to delete unbound core "
"context\n");
}
if (wglGetCurrentContext() != legacy &&
wglMakeCurrent(dc_, legacy) != TRUE) {
errorOut =
"failed to restore the legacy WGL context";
return false;
}
context_ = legacy;
} else {
context_ = core;
if (wglDeleteContext(legacy) != TRUE) {
std::fprintf(
stderr,
"wgl create: failed to delete superseded legacy "
"context\n");
}
}
} else {
/* Pre-3.2 driver: keep the legacy (compatibility) context;
* the entry-point loading below decides adequacy. */
context_ = legacy;
}
} else {
context_ = legacy;
}
bool loaded = true;
const char* missing = nullptr;
#define FRAME_HELPER_WGL_LOAD(ret, name, args) \
name = reinterpret_cast<ret(APIENTRY*) args>( \
wglLoaderGetProcAddress(opengl32_, #name)); \
if (!name && loaded) { \
loaded = false; \
missing = #name; \
}
FRAME_HELPER_WGL_ENTRY_POINTS(FRAME_HELPER_WGL_LOAD)
#undef FRAME_HELPER_WGL_LOAD
if (!loaded) {
errorOut = std::string("missing GL entry point: ") + missing;
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
errorOut += "; failed to release the WGL context";
}
return false;
}
/* Unbind before returning — see the threading contract above. */
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
errorOut =
"failed to release the WGL context for render-thread handoff";
return false;
}
return true;
}
bool makeCurrent(std::string& errorOut) {
if (!dc_ || !context_) {
errorOut = "WGL render context is unavailable";
return false;
}
if (wglMakeCurrent(dc_, context_) != TRUE) {
errorOut = "failed to bind the WGL render context";
return false;
}
return true;
}
void destroy() {
if (context_ && wglMakeCurrent(nullptr, nullptr) != TRUE) {
std::fprintf(stderr,
"wgl teardown: failed to release current context\n");
return;
}
if (context_ && wglDeleteContext(context_) != TRUE) {
std::fprintf(stderr, "wgl teardown: failed to delete context\n");
return;
}
context_ = nullptr;
/* The hidden window/class belong to the main thread (DestroyWindow
* is thread-affine and this runs on the render thread); the process
* exits right after, so they are left to OS teardown. */
dc_ = nullptr;
window_ = nullptr;
}
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
void* procLoaderCtx() const { return opengl32_; }
private:
HWND window_ = nullptr;
HDC dc_ = nullptr;
HGLRC context_ = nullptr;
HMODULE opengl32_ = nullptr;
};
#else
#error "frame_helper_gl.h has no GL context implementation for this platform"
#endif
@@ -14,9 +14,11 @@
#include <mpv/client.h>
#include <mpv/render_gl.h>
#if !defined(_WIN32)
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#endif
#include <atomic>
#include <chrono>
@@ -39,19 +41,47 @@ struct ShmRing {
uint8_t* base = nullptr;
size_t size = 0;
std::string name;
#if defined(_WIN32)
/* Named sections are refcounted: this handle keeps the mapping alive
* for the helper's lifetime; readers holding views keep the memory
* valid after the helper exits, matching unlinked-POSIX-shm semantics. */
HANDLE mapping = nullptr;
#endif
bool create(const std::string& shmName, int width, int height,
uint32_t generation) {
destroy();
shm_unlink(shmName.c_str());
const int fd = shm_open(shmName.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd < 0) return false;
const uint64_t frameBytes = (uint64_t)width * 4u * (uint64_t)height;
const uint64_t dataOffset =
(sizeof(FrameShmHeader) + FRAME_SHM_DATA_ALIGN - 1) &
~(uint64_t)(FRAME_SHM_DATA_ALIGN - 1);
const size_t total =
(size_t)(dataOffset + FRAME_SHM_RING_SLOTS * frameBytes);
#if defined(_WIN32)
char mappingName[256];
frame_shm_windows_name(shmName.c_str(), mappingName,
sizeof(mappingName));
HANDLE created = CreateFileMappingA(
INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
(DWORD)(total >> 32), (DWORD)(total & 0xffffffffu), mappingName);
if (!created) return false;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
/* Never adopt an existing section: its layout is unknown. The
* per-session UUID + generation suffix make collisions mean a
* bug, not a retry. */
CloseHandle(created);
return false;
}
void* mapped = MapViewOfFile(created, FILE_MAP_ALL_ACCESS, 0, 0, 0);
if (!mapped) {
CloseHandle(created);
return false;
}
mapping = created;
#else
shm_unlink(shmName.c_str());
const int fd = shm_open(shmName.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd < 0) return false;
if (ftruncate(fd, (off_t)total) != 0) {
close(fd);
shm_unlink(shmName.c_str());
@@ -64,6 +94,7 @@ struct ShmRing {
shm_unlink(shmName.c_str());
return false;
}
#endif
std::memset(mapped, 0, sizeof(FrameShmHeader));
auto* hdr = static_cast<FrameShmHeader*>(mapped);
hdr->version = FRAME_SHM_VERSION;
@@ -89,10 +120,16 @@ struct ShmRing {
}
void destroy() {
#if defined(_WIN32)
if (base) UnmapViewOfFile(base);
if (mapping) CloseHandle(mapping);
mapping = nullptr;
#else
if (base) {
munmap(base, size);
shm_unlink(name.c_str());
}
#endif
header = nullptr;
base = nullptr;
size = 0;
@@ -6,14 +6,29 @@
* resize creates a fresh shm segment named `<base>-g<generation>` and the
* reader re-attaches when the helper announces the new generation.
*
* Must compile as C11 (reader addon) and C++17 (helper). Both consumers
* build with node-gyp's GNU dialects; frame_shm_now_ns() relies on POSIX
* clock_gettime, which strict -std=c11 (__STRICT_ANSI__) would hide.
* Must compile as C11 (reader addon) and C++17 (helper) on POSIX; on
* Windows both consumers build as C++ (MSVC's C mode lacks <stdatomic.h>).
* POSIX builds use node-gyp's GNU dialects; frame_shm_now_ns() relies on
* clock_gettime there, which strict -std=c11 (__STRICT_ANSI__) would hide.
*/
#pragma once
#include <stdint.h>
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <stdio.h>
#include <string.h>
#else
#include <time.h>
#endif
#ifdef __cplusplus
#include <atomic>
@@ -25,14 +40,44 @@ typedef _Atomic uint64_t frame_shm_atomic_u64;
/* 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. */
* POSIX: CLOCK_MONOTONIC (macOS 10.12+, Linux). Windows: QPC, scaled to ns
* without overflow by splitting whole seconds from the remainder. */
static inline uint64_t frame_shm_now_ns(void) {
#if defined(_WIN32)
/* C++11 function-local initialization is synchronized, so the helper's
* render/event threads cannot race while caching the boot-stable QPF. */
static const uint64_t frequency = []() -> uint64_t {
LARGE_INTEGER value;
if (QueryPerformanceFrequency(&value) != TRUE ||
value.QuadPart <= 0) {
return 1;
}
return (uint64_t)value.QuadPart;
}();
LARGE_INTEGER counter;
QueryPerformanceCounter(&counter);
const uint64_t ticks = (uint64_t)counter.QuadPart;
return (ticks / frequency) * 1000000000ull +
(ticks % frequency) * 1000000000ull / frequency;
#else
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
#endif
}
#if defined(_WIN32)
/* The stdio protocol carries POSIX-style names ("/impv-...-gN") on every
* platform so the TypeScript layer stays platform-agnostic; on Windows the
* helper (create) and reader (open) both derive the actual file-mapping
* object name from it: session-local namespace, slashes stripped. */
static inline void frame_shm_windows_name(const char* posix_name, char* out,
size_t out_size) {
while (*posix_name == '/') posix_name++;
snprintf(out, out_size, "Local\\%s", posix_name);
}
#endif
#define FRAME_SHM_MAGIC 0x564d5046u /* 'FPMV' */
#define FRAME_SHM_VERSION 1u
#define FRAME_SHM_RING_SLOTS 3u
@@ -1,6 +1,6 @@
/*
* iptvnator-mpv-helper — frame-copy embedded MPV helper process
* (macOS + Linux; platform GL context in frame_helper_gl.h).
* (macOS + Linux + Windows; platform GL context in frame_helper_gl.h).
*
* One process = one playback session. Owns libmpv end to end: decodes,
* renders offscreen at viewport size, publishes BGRA frames into a shared
@@ -101,7 +101,11 @@ std::string isoTimestampNow() {
char buffer[32];
const time_t now = time(nullptr);
struct tm utc;
#if defined(_WIN32)
gmtime_s(&utc, &now);
#else
gmtime_r(&now, &utc);
#endif
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
return buffer;
}
@@ -633,7 +637,9 @@ HelperArgs parseArgs(int argc, char** argv) {
int main(int argc, char** argv) {
std::setlocale(LC_NUMERIC, "C");
#if !defined(_WIN32)
signal(SIGPIPE, SIG_IGN);
#endif
const HelperArgs args = parseArgs(argc, argv);
g_state.mpv = mpv_create();
@@ -725,9 +731,14 @@ int main(int argc, char** argv) {
mpv_render_context_set_update_callback(g_state.pipeline.renderContext(),
onRenderUpdate, &g_state.pipeline);
#if defined(_WIN32)
const double helperPid = (double)GetCurrentProcessId();
#else
const double helperPid = (double)getpid();
#endif
emitLine(JsonWriter()
.str("event", "hello")
.num("pid", (double)getpid())
.num("pid", helperPid)
.num("protocolVersion", 1)
.finish());
@@ -7,13 +7,30 @@
* memory cage forbids external ArrayBuffers over foreign memory.
*
* Plain C N-API (no node-addon-api) so it stays ABI-stable and trivial.
* macOS and Linux (POSIX shm) — other platforms export an empty object;
* the TypeScript side gates on platform before requiring it.
* macOS and Linux (POSIX shm) plus Windows (named file mapping; binding.gyp
* compiles this file as C++ there because MSVC's C mode has no
* <stdatomic.h>) — other platforms export an empty object; the TypeScript
* side gates on platform before requiring it.
*/
#define NAPI_VERSION 8
#include <node_api.h>
#if defined(__APPLE__) || defined(__linux__)
#if defined(__APPLE__) || defined(__linux__) || defined(_WIN32)
#if defined(_WIN32)
#include <stdint.h>
#include <string.h>
#include "../helper/frame_shm.h"
/* frame_shm.h typed the fields as std::atomic in C++ mode; keep the shared
* atomic_load_explicit call sites below spelling-compatible. */
using std::atomic_load_explicit;
using std::memory_order_acquire;
using std::memory_order_relaxed;
#else
#include <fcntl.h>
#include <stdatomic.h>
@@ -26,6 +43,8 @@
#include "../helper/frame_shm.h"
#endif
static FrameShmHeader* g_header = NULL;
static uint8_t* g_base = NULL;
static size_t g_size = 0;
@@ -35,7 +54,11 @@ static uint64_t now_ns(void) { return frame_shm_now_ns(); }
static void unmap_current(void) {
if (g_base) {
#if defined(_WIN32)
UnmapViewOfFile(g_base);
#else
munmap(g_base, g_size);
#endif
}
g_base = NULL;
g_header = NULL;
@@ -68,6 +91,23 @@ static napi_value Open(napi_env env, napi_callback_info info) {
return throw_error(env, "shm name must be a string");
}
#if defined(_WIN32)
char mapping_name[280];
frame_shm_windows_name(name, mapping_name, sizeof(mapping_name));
HANDLE mapping = OpenFileMappingA(FILE_MAP_READ, FALSE, mapping_name);
if (!mapping) return throw_error(env, "shm open failed (helper gone?)");
void* mapped = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0);
/* The view holds its own reference to the section object. */
CloseHandle(mapping);
if (!mapped) return throw_error(env, "shm map failed");
MEMORY_BASIC_INFORMATION region;
if (VirtualQuery(mapped, &region, sizeof(region)) == 0 ||
region.RegionSize < sizeof(FrameShmHeader)) {
UnmapViewOfFile(mapped);
return throw_error(env, "shm segment too small");
}
const size_t segment_size = (size_t)region.RegionSize;
#else
const int fd = shm_open(name, O_RDONLY, 0);
if (fd < 0) return throw_error(env, "shm_open failed (helper gone?)");
struct stat segment;
@@ -80,17 +120,23 @@ static napi_value Open(napi_env env, napi_callback_info info) {
mmap(NULL, (size_t)segment.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (mapped == MAP_FAILED) return throw_error(env, "mmap failed");
const size_t segment_size = (size_t)segment.st_size;
#endif
FrameShmHeader* header = (FrameShmHeader*)mapped;
if (header->magic != FRAME_SHM_MAGIC ||
header->version != FRAME_SHM_VERSION) {
munmap(mapped, (size_t)segment.st_size);
#if defined(_WIN32)
UnmapViewOfFile(mapped);
#else
munmap(mapped, segment_size);
#endif
return throw_error(env, "frame ring not initialized yet");
}
unmap_current();
g_base = (uint8_t*)mapped;
g_size = (size_t)segment.st_size;
g_size = segment_size;
g_header = header;
napi_value result;
@@ -204,7 +250,7 @@ static napi_value Init(napi_env env, napi_value exports) {
return exports;
}
#else /* neither __APPLE__ nor __linux__ */
#else /* no frame-copy port for this platform */
static napi_value Init(napi_env env, napi_value exports) {
/* Frame-copy is not ported to this platform yet; loading this module
+20 -2
View File
@@ -52,7 +52,16 @@
".deps/**",
"obj.target/**",
"**/.deps/**",
"**/obj.target/**"
"**/obj.target/**",
"obj/**",
"**/obj/**",
"**/*.pdb",
"**/*.iobj",
"**/*.ipdb",
"**/*.ilk",
"**/*.exp",
"**/*.lib",
"**/*.dll.a"
]
}
]
@@ -105,7 +114,16 @@
".deps/**",
"obj.target/**",
"**/.deps/**",
"**/obj.target/**"
"**/obj.target/**",
"obj/**",
"**/obj/**",
"**/*.pdb",
"**/*.iobj",
"**/*.ipdb",
"**/*.ilk",
"**/*.exp",
"**/*.lib",
"**/*.dll.a"
]
}
]
@@ -32,7 +32,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['win32', 'x64', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
Object.defineProperty(process, 'platform', { value: platform });
Object.defineProperty(process, 'arch', { value: arch });
@@ -54,8 +55,14 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
'build',
'Release'
);
// The resolver looks for the host platform's binary name, so the
// fixture must follow it for the spec to stay host-agnostic.
const helperFileName = () =>
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper';
const helperPath = () =>
path.join(releaseDir(), 'iptvnator_mpv_helper');
path.join(releaseDir(), helperFileName());
const readerPath = () =>
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
@@ -134,7 +141,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
mkdirSync(packagedNativeDir, { recursive: true });
const packagedHelper = path.join(
packagedNativeDir,
'iptvnator_mpv_helper'
helperFileName()
);
writeFileSync(packagedHelper, '#!/bin/sh\n');
chmodSync(packagedHelper, 0o755);
@@ -8,12 +8,17 @@ import path from 'path';
* sandbox and engine decisions cannot drift. These helpers must remain
* callable before app.whenReady().
*
* macOS stays Apple-Silicon-only. Linux supports every architecture because
* its helper renders through headless EGL and links libmpv out of process.
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
* the docked native engine. Linux: any arch — the helper renders offscreen
* through headless EGL and links libmpv out of process, so neither window
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
* with a helper binary (WGL offscreen render; in practice x64, the only
* vendored runtime) — the helper-presence check below is the real gate.
*/
export function isFrameCopyPlatformSupported(): boolean {
return (
process.platform === 'linux' ||
process.platform === 'win32' ||
(process.platform === 'darwin' && process.arch === 'arm64')
);
}
@@ -80,7 +85,8 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] {
/**
* Resolve the first executable frame-copy helper with a readable regular-file
* frame reader beside it. Both artifacts are required before the engine may
* relax the renderer sandbox.
* relax the renderer sandbox. Windows uses the `.exe` helper name; X_OK is an
* existence check there, while POSIX platforms also require the execute bit.
*/
export function resolveFrameCopyHelperPath(): string | null {
// Packaged startup must never fall through to cwd/dist development
@@ -89,12 +95,15 @@ export function resolveFrameCopyHelperPath(): string | null {
const addonCandidates = app.isPackaged
? getPackagedAddonPaths()
: [getLocalBuildAddonPath(), ...getDistAddonPaths()];
const helperFileName =
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper';
return (
addonCandidates
.map((candidatePath) => path.dirname(candidatePath))
.map((nativeDir) => ({
helper: path.join(nativeDir, 'iptvnator_mpv_helper'),
helper: path.join(nativeDir, helperFileName),
reader: path.join(
nativeDir,
'embedded_mpv_frame_reader.node'
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import path from 'path';
const spawnMock = jest.fn();
jest.mock('child_process', () => ({
@@ -117,8 +118,10 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
expect(frameSourceChanges).toEqual([
{ sessionId, shmName: `/${sessionId}-g1` },
]);
// path.join output is host-specific; build the expectation the
// same way so the spec passes on Windows checkouts too.
expect(adapter.getFrameSource(sessionId)?.readerPath).toBe(
'/native/embedded_mpv_frame_reader.node'
path.join('/native', 'embedded_mpv_frame_reader.node')
);
});
@@ -190,7 +193,8 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['win32', 'x64', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])(
'on %s/%s with a helper binary present -> %s',
(platform, arch, expected) => {
@@ -40,19 +40,23 @@ describe('Embedded MPV native source recording invariants', () => {
'utf8'
);
const frameHelperRenderSource = readFileSync(
path.resolve(
__dirname,
'../../../native/helper/frame_helper_render.h'
),
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
);
const frameShmSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_shm.h'),
'utf8'
);
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
);
const windowsFrameHelperGlSource = frameHelperGlSource.slice(
frameHelperGlSource.indexOf('#define FRAME_HELPER_WGL_ENTRY_POINTS')
);
function functionBody(name: string): string {
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
@@ -304,9 +308,7 @@ describe('Embedded MPV native source recording invariants', () => {
});
it('copies Windows runtime DLLs next to the addon for Windows loader lookup', () => {
expect(buildScriptSource).toContain(
"'libmpv-2.dll',"
);
expect(buildScriptSource).toContain("'libmpv-2.dll',");
expect(buildScriptSource).toContain(
'path.join(outputDir, windowsDllName)'
);
@@ -719,6 +721,57 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('checks WGL context handoff and never deletes a context after failed unbind', () => {
const create = sourceFunctionBody(
windowsFrameHelperGlSource,
'bool create(std::string& errorOut)',
'WGL GlContext::create'
);
const makeCurrent = sourceFunctionBody(
windowsFrameHelperGlSource,
'bool makeCurrent(std::string& errorOut)',
'WGL GlContext::makeCurrent'
);
const destroy = sourceFunctionBody(
windowsFrameHelperGlSource,
'void destroy()',
'WGL GlContext::destroy'
);
expect(create).toContain('wglMakeCurrent(dc_, core) != TRUE');
expect(create).toContain('wglMakeCurrent(nullptr, nullptr) != TRUE');
expect(create).toContain('wglLoaderGetProcAddress(opengl32_,');
expect(create).toContain('"wglCreateContextAttribsARB")');
expect(create).not.toContain(
'wglGetProcAddress("wglCreateContextAttribsARB")'
);
expect(makeCurrent).toContain('wglMakeCurrent(dc_, context_) != TRUE');
expect(makeCurrent).toContain('return false;');
const unbindIndex = destroy.indexOf(
'wglMakeCurrent(nullptr, nullptr) != TRUE'
);
const deleteIndex = destroy.indexOf(
'wglDeleteContext(context_) != TRUE'
);
expect(unbindIndex).toBeGreaterThanOrEqual(0);
expect(deleteIndex).toBeGreaterThan(unbindIndex);
expect(destroy.slice(0, deleteIndex)).toContain('return;');
});
it('initializes the Windows QPC frequency without a cross-thread data race', () => {
const nowNs = sourceFunctionBody(
frameShmSource,
'static inline uint64_t frame_shm_now_ns(void)',
'frame_shm_now_ns'
);
expect(nowNs).toContain(
'static const uint64_t frequency = []() -> uint64_t'
);
expect(nowNs).not.toContain('static uint64_t frequency;');
});
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
'dlsym(RTLD_DEFAULT, name)'
@@ -111,9 +111,9 @@ export class EmbeddedMpvNativeService {
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10) and
* Linux, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on
* top of the regular embedded MPV experiment flag.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
* and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1
* on top of the regular embedded MPV experiment flag.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
@@ -215,9 +215,9 @@ export class EmbeddedMpvNativeService {
};
}
// The frame-copy engine renders offscreen (headless EGL on Linux)
// into a renderer canvas: the Linux X11/Xwayland and system-mpv
// requirements below only bind the native --wid engine. Both
// The frame-copy engine renders offscreen (headless EGL on Linux,
// WGL on Windows) into a renderer canvas: the Linux X11/Xwayland and
// system-mpv requirements below only bind the native --wid engine. Both
// native-engine failure returns still advertise frameCopyAvailable
// so the Settings toggle stays reachable — otherwise the states the
// frame-copy engine exists to fix would hide the way to enable it.
@@ -184,7 +184,9 @@
</div>
}
@if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -256,7 +258,9 @@
</div>
}
@if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -171,6 +171,30 @@ describe('SettingsPlaybackSectionComponent', () => {
).toBeNull();
});
it.each<[string, boolean, boolean, boolean, boolean]>([
['shows the available desktop engine', true, true, false, true],
['keeps a stale desktop opt-in clearable', true, false, true, true],
['hides a stale opt-in in the PWA', false, false, true, false],
])(
'%s',
(_description, isDesktop, frameCopyAvailable, stored, expected) => {
const form = createForm();
form.controls['embeddedMpvFrameCopy'].setValue(stored);
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', isDesktop);
fixture.componentRef.setInput(
'frameCopyAvailable',
frameCopyAvailable
);
fixture.detectChanges();
const setting = fixture.nativeElement.querySelector(
'[data-test-id="embedded-mpv-frame-copy-setting"]'
);
expect(Boolean(setting)).toBe(expected);
}
);
it('shows the recording folder setting only in desktop builds', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.detectChanges();
@@ -364,6 +388,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
embeddedMpvFrameCopy: new FormControl(false),
mpvPlayerPath: new FormControl(''),
mpvPlayerArguments: new FormControl(''),
mpvReuseInstance: new FormControl(false),
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon وعلى Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon و Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon і Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen. Nur macOS auf Apple Silicon und Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon και Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it. macOS (Apple Silicon) and Linux only.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima. Solo macOS con Apple Silicon y Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus. macOS Apple Silicon et Linux uniquement.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso. Solo macOS con Apple Silicon e Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSおよびLinuxのみ。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 및 Linux 전용.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen. Alleen macOS op Apple Silicon en Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim. Tylko macOS na Apple Silicon i Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima. Apenas macOS com Apple Silicon e Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon и Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir. Yalnızca Apple Silicon macOS ve Linux.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS 和 Linux。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS 和 Linux。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
},
"THEMES": {
+47 -27
View File
@@ -18,8 +18,8 @@ Source files for the embedded MPV integration:
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
Frame-copy engine sources (experimental, macOS Apple Silicon and Linux —
see the "Frame-Copy Engine" section below):
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and
Windows — see the "Frame-Copy Engine" section below):
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`).
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API shm frame reader used by the preload frame pump.
@@ -41,7 +41,11 @@ Windows packaged runtimes must preserve the MPV DLL basename referenced by the
import library used at native-addon link time. For example, an archive that
ships `libmpv.dll.a` and `libmpv-2.dll` must package `libmpv-2.dll`; renaming it
to `mpv-2.dll` leaves `embedded_mpv.node` with an unresolved DLL dependency at
startup, so the Settings support probe hides the Embedded MPV option.
startup, so the Settings support probe hides the Embedded MPV option. The
frame-copy helper is a separate executable and resolves that same import from
its own directory. Package validation therefore reads the helper's PE import
table and requires the exact referenced MPV DLL beside
`iptvnator_mpv_helper.exe`, not only under `native/lib/`.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. The Linux MPV JSON IPC polling runs on an addon-owned background thread; `getSessionSnapshot()` returns the last cached snapshot and must not perform socket round trips on Electron's main thread. Linux MPV process teardown sends `SIGTERM` on the caller path, then waits and escalates to `SIGKILL` on a detached cleanup thread. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
@@ -100,11 +104,11 @@ The MPV video surface is a native platform view/window, not a normal DOM element
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
## Frame-Copy Engine (Experimental, Apple Silicon and Linux)
## Frame-Copy Engine (Experimental, Apple Silicon, Linux and Windows)
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
embedded MPV experiment flag) switches macOS/arm64 and Linux to a second
rendering engine that replaces the native-view compositing entirely
embedded MPV experiment flag) switches macOS/arm64, Linux and Windows to a
second rendering engine that replaces the native-view compositing entirely
(gate: `isFrameCopyPlatformSupported()` in
`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the
service and the adapter):
@@ -115,14 +119,16 @@ service and the adapter):
context — `frame_helper_gl.h`: CGL on macOS; on Linux EGL, acquiring a
display in order surfaceless-Mesa → default display → GBM render node;
every tier must complete config/context/bind validation, hardware rendering
is preferred, and a software tier is retained only as the final fallback),
publishes BGRA frames into a POSIX
shm seqlock ring
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
plays audio directly. Control protocol: tab-separated commands on stdin,
JSON events on stdout; the `snapshot` event mirrors
`NativeEmbeddedMpvSessionSnapshot`. Status semantics are ported from
`embedded_mpv.mm`.
is preferred, and a software tier is retained only as the final fallback;
on Windows WGL against a hidden window, bootstrapping a 3.2 core context
through `wglCreateContextAttribsARB`), publishes BGRA frames into a shm
seqlock ring (`frame_shm.h`, 3 slots, resize creates a new `-g<N>`
generation — POSIX shm on macOS/Linux, a `Local\` named file mapping on
Windows; the protocol carries POSIX-style `/impv-*` names everywhere and
the native sides derive the mapping name), and plays audio directly.
Control protocol: tab-separated commands on stdin, JSON events on stdout;
the `snapshot` event mirrors `NativeEmbeddedMpvSessionSnapshot`. Status
semantics are ported from `embedded_mpv.mm`.
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` —
implements the same `NativeEmbeddedMpvAddon` surface over the helper
process, so `EmbeddedMpvNativeService` (polling, diffing, power blocker,
@@ -179,18 +185,20 @@ Lifecycle safety: `EmbeddedMpvNativeService` watches the main window for
session — Angular teardown never runs on a renderer crash/hard reload, and
without the watch helper processes (or native mpv handles) would leak until
app shutdown. Unexpected helper exits surface as a session `error`. macOS
package validation requires `iptvnator_mpv_helper` and
and Windows package validation requires the helper
(`iptvnator_mpv_helper` / `iptvnator_mpv_helper.exe`) and
`embedded_mpv_frame_reader.node` next to the addon whenever the addon
ships. The after-pack hook restores the helper's executable mode after the
asset copy, and optional/skipped native rebuilds remove stale helper/reader
artifacts before reporting frame-copy availability. This cleanup prevents
known leftover build output; it is not a compatibility check for a complete
but version-mismatched runtime pair. Linux packages deliberately do NOT ship
the helper yet: it links the build host's system `libmpv`, which packaged
apps cannot assume is installed, so the centralized after-pack artifact
preparation strips both possible helper basenames and package validation
rejects either one if it survives. The support probe therefore reports
frame-copy unavailable in Linux packages.
ships; on Windows the bundled mpv DLL is also copied beside the helper so
the executable resolves it from its own directory. The after-pack hook
restores the POSIX helper's executable mode after the asset copy, and
optional/skipped native rebuilds remove stale helper/reader artifacts before
reporting frame-copy availability. This cleanup prevents known leftover build
output; it is not a compatibility check for a complete but version-mismatched
runtime pair. Linux packages deliberately do NOT ship the helper yet: it links
the build host's system `libmpv`, which packaged apps cannot assume is
installed, so centralized after-pack preparation strips both possible helper
basenames and package validation rejects either one if it survives. The
support probe therefore reports frame-copy unavailable in Linux packages.
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
lands (PORTING.md milestone 4).
@@ -213,8 +221,20 @@ Trade-offs and constraints:
string to stderr. If an early tier selects Mesa software rendering (for
example, while a proprietary NVIDIA driver is reachable through the default
display or GBM), it probes the remaining tiers and uses software only when
no hardware-backed context works. The Windows port of the helper (WGL) is
future work — the shm protocol and adapter are platform-agnostic.
no hardware-backed context works. Windows (any arch with a helper, in
practice x64) is ported: WGL renders offscreen against a hidden window,
the shm ring is a session-local named file mapping, and the reader addon
compiles as C++ there (MSVC has no C11 `<stdatomic.h>`). The helper
links the vendored libmpv import library and loads the DLL from its own
directory. Windows 11 Smart App Control blocks unsigned locally-built
executables — turn it off on dev machines or the helper cannot spawn
(the support probe still reports available; the session errors).
Runtime trait, not frame-copy-specific: the vendored mpv-winbuild
libmpv routes http(s) through mpv's curl stream backend and ships no CA
bundle, so https streams currently fail TLS verification (`mpv/curl`
errors in the helper log); the in-process native engine links the same
DLL and shares the trait. Resolving the CA story belongs to Windows
runtime packaging, not to either engine.
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload latency, zero torn frames over a 10-minute run.
+49 -33
View File
@@ -1,9 +1,8 @@
# 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.
> Historical handoff and maintenance notes for the completed Linux and
> Windows ports. The canonical runtime contract now lives in
> `docs/architecture/embedded-mpv-native.md`.
## State as of 2026-07-15
@@ -19,12 +18,28 @@
(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.
that strip when milestone 4 lands.
- **The Windows port below landed through PR #1175** — WGL GlContext twin,
QPC clock + `Local\` named-file-mapping
shm twins (the protocol keeps POSIX-style `/impv-*` names; the native
sides derive the mapping name), reader compiled as C++ on `_WIN32`
(MSVC has no C11 `<stdatomic.h>`), helper `.exe` binding.gyp target
linking the vendored import lib + opengl32 (mpv DLL resolved from the
exe's own directory), TS gates + `.exe` helper discovery, i18n,
packaging/CI guards (win32 packages must ship helper + reader; MSVC
intermediates and import libs excluded from dist). **The open iGPU perf
gate is CLOSED**: RESULTS.md rows on the same i7-1165G7/Iris Xe laptop
as the Linux section (dual boot) — 1080p60 sustained (clean 60 s run),
the viewport-price claim reproduces, d3d11va hwdec active (5.5× CPU
drop), torn=0 everywhere. Machine gotchas for future sessions: Windows
11 Smart App Control must be OFF to run locally-built unsigned helpers,
and a fresh Windows install can leave the iGPU on the Basic Display
Adapter — bind the real Intel driver (`pnputil /remove-device` +
`/scan-devices` once the driver is in the store) or WGL has no 3.2 core
context and no d3d11va.
- 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.
controls remain a separate integration after this platform stack; do not
conflate that UI layer with the frame-copy transport ports.
## What "porting" means
@@ -33,35 +48,32 @@ 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:
apps/electron-backend/native/helper/ # state after all three ports:
├── 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
├── frame_shm.h # portable layout + shared clock (POSIX
│ # CLOCK_MONOTONIC / Windows QPC) + the Windows
│ # Local\ mapping-name derivation
├── frame_helper_render.h # portable render + ShmRing (POSIX shm_open /
│ # Windows CreateFileMapping twins)
└── frame_helper_gl.h # PLATFORM SEAM: GlContext — CGL (macOS),
# EGL (Linux) and WGL (Windows)
apps/electron-backend/native/src/embedded_mpv_frame_reader.c
# real impl on __APPLE__ + __linux__, stub
# elsewhere (Windows needs shm-open/clock twins)
# real impl on __APPLE__ + __linux__ + _WIN32
# (compiled as C++ there), stub elsewhere
```
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.
All three ports have landed; new platforms follow the same seams: a
GlContext twin in `frame_helper_gl.h`, shm create/open + `frame_shm_now_ns`
twins, the TS gate in `embedded-mpv-frame-copy-platform.util.ts`, 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.
- The platform stack landed in order #1169 → #1171 → #1175.
- Each child was rewritten onto the latest squash-merged `master` using only
its platform-specific commit range; the old parent history was not replayed.
- New frame-copy work branches directly from `master`. Keep future stacks at
most one unmerged level deep and land platform measurements with their port.
## Per-platform task lists
@@ -95,7 +107,11 @@ shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
the Linux support matrix in `docs/architecture/embedded-mpv-native.md`
when this lands.
### Windows
### Windows (DONE 2026-07-12 — see the update in "State" above)
One deviation from the checklist below: the WGL context uses a hidden
regular window, not a message-only one — SetPixelFormat needs a
display-capable DC.
1. **Render backend**: WGL headless — create a hidden message-only window +
dummy pixel format, `wglCreateContextAttribsARB` 3.2 core, then the same
@@ -195,10 +211,10 @@ shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
`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
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
--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,
+46 -2
View File
@@ -143,6 +143,50 @@ Readings:
- EGL display tier used: Mesa surfaceless platform (first tier; no display
server needed).
## Windows mid-range laptop (iGPU) — PENDING
## Windows mid-range laptop (iGPU) — Windows 11 Home 26200, i7-1165G7 / Iris Xe, x64 — 2026-07-12
Blocked on the Windows helper port (WGL or D3D11 readback path).
Source: Windows port branch (WGL `frame_helper_gl.h` backend), vendored
libmpv from zhongfly/mpv-winbuild 2026-06-14 (`git-7d245fd100`,
`libmpv-2.dll`), Intel driver 30.0.101.1340. **Same physical laptop as the
Linux section above** (TUXEDO Book XP14 Gen12, dual boot), so the two
sections compare OS/driver stacks on identical hardware. Measured with the
production helper + `embedded_mpv_frame_reader.node` through
`linux-frame-probe.mjs` (now cross-platform; on Windows it polls via
setImmediate because setTimeout quantizes to the ~15.6 ms system timer,
which would dominate *age*), so *age* is produce→reader-copy and excludes
the renderer texture upload — same semantics as the Linux rows. Unlike the
Linux rows, hwdec IS available here: mpv's `hwdec=auto` engages d3d11va
(verified by helper CPU: 0.51 core-s/s vs 2.82 core-s/s with `--hwdec no`
on the 4K row — a 5.5× CPU drop). Test clip generated with `hevc_qsv`
(the Intel encoder), so decode complexity is not byte-identical to the
videotoolbox/x265 clips of the other sections.
| Scenario | New fps | copy ms avg/p95 | age ms avg/p95 | torn |
| --- | --- | --- | --- | --- |
| 1080p60 testsrc2, sw | 60.0 | 1.80 / 2.10 | 1.83 / 2.14 | 0 |
| 4K60 testsrc2, sw | 56.0 | 8.81 / 9.88 | 8.83 / 9.86 | 0 |
| 4K60 HEVC 25 Mbit, hwdec=d3d11va | 41.1 | 8.57 / 10.4 | 8.71 / 10.4 | 0 |
| 4K60 HEVC 25 Mbit, sw decode | 45.7 | 10.3 / 15.1 | 10.4 / 15.4 | 0 |
| 4K60 HEVC 25 Mbit in a 1280×720 viewport, hwdec | 60.2 | 1.29 / 1.87 | 1.27 / 1.82 | 0 |
Readings:
- 1080p60 — the realistic viewport class for this laptop's 1920×1200
screen — holds a clean 60 fps with ~1.8 ms copies. A 60-second sustained
run kept 60.0 fps over 3601 frames (copy 1.57 / 1.84 ms, torn 0).
- The 4K rows are stress rows, as on Linux: at a full-4K viewport the
Iris Xe is saturated by mpv render + readback (56 fps ceiling with no
decode at all), so d3d11va decode — which shares the same iGPU — buys
CPU headroom (5.5×), not fps; sw decode trades ~2.3 cores for ~4 fps.
- The viewport-size claim reproduces on Windows: the same 4K60 HEVC clip
in a 720p viewport runs 60 fps with 1.3 ms copies and hardware decode
active.
- torn=0 across every run; the aspect-fit generation bump was verified
(4:3 960×720 source in a 1280×720 viewport → `-g2` at 960×720).
- WGL context: `gl renderer: Intel(R) Iris(R) Xe Graphics` (hardware,
3.2 core via wglCreateContextAttribsARB). Machine prerequisite hit
during bring-up: Windows 11 Smart App Control blocks locally-built
unsigned executables (the helper) until turned off, and a fresh Windows
install may run the iGPU on the Basic Display Adapter — the frame-copy
engine needs the real Intel driver bound (WGL on the basic adapter has
no 3.2 core context, and there is no d3d11va).
+15 -3
View File
@@ -1,5 +1,5 @@
/*
* Linux frame-copy probe: spawns iptvnator_mpv_helper, attaches the
* Linux/Windows frame-copy probe: spawns iptvnator_mpv_helper, attaches the
* embedded_mpv_frame_reader addon to the announced shm generation, and
* reports producer fps, copy latency (ageMs), copy wall time, torn reads
* and pixel spread. Usage:
@@ -22,7 +22,12 @@ const hwdecIdx = rest.indexOf('--hwdec');
const hwdec = hwdecIdx >= 0 ? rest[hwdecIdx + 1] : null;
const reader = require(path.join(releaseDir, 'embedded_mpv_frame_reader.node'));
const helperPath = path.join(releaseDir, 'iptvnator_mpv_helper');
const helperPath = path.join(
releaseDir,
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper'
);
const shmBase = `/impv-probe-${process.pid}`;
const args = ['--shm-base', shmBase, '--width', width, '--height', height];
@@ -56,6 +61,13 @@ child.stdout.on('data', (chunk) => {
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* Poll yield between latestSeq checks: Windows quantizes setTimeout to the
* ~15.6 ms system timer, which would dominate ageMs; setImmediate keeps the
* poll sub-ms there at the cost of one busy event loop. */
const pollYield =
process.platform === 'win32'
? () => new Promise((r) => setImmediate(r))
: () => sleep(2);
await sleep(300);
child.stdin.write(`load\turl=${url.replace(/%/g, '%25')}\n`);
@@ -102,7 +114,7 @@ while (Date.now() - start < durationMs) {
if (result.torn) torn += 1;
}
}
await sleep(2);
await pollYield();
}
const elapsed = (Date.now() - start) / 1000;
+6 -3
View File
@@ -1,6 +1,6 @@
# Embedded MPV Runtime
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon.
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon. On Windows the same binding.gyp run builds `iptvnator_mpv_helper.exe` against the staged vendored runtime (import library + DLL, resolved from the helper's own directory at runtime) plus `opengl32.lib`; no toolchain beyond the MSVC workload and Windows SDK that node-gyp already requires.
## Runtime Policy
@@ -110,7 +110,10 @@ libmpv import library, and `mpv-2.dll`/`mpv.dll` or
(`lib/` and `bin/`) or the common `mpv-dev-lgpl` flat layout with the import
library and DLL in the archive root. The staged runtime preserves the DLL
basename from the archive because Windows import libraries encode the DLL name
that `embedded_mpv.node` must load at runtime. If
that native binaries must load at runtime. Package validation reads the
frame-copy helper's PE imports and requires that exact DLL basename beside
`iptvnator_mpv_helper.exe`; a different accepted MPV DLL name or a copy only
under `native/lib/` is not sufficient. If
`runtime-manifest.json` is missing, CI generates a minimal manifest from the
archive URL/path and checksum; release-ready runtime archives should still
provide full source/build metadata.
@@ -147,5 +150,5 @@ Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that mu
## Platform Notes
- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS.
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`.
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`. The experimental frame-copy engine instead renders offscreen through WGL into the app canvas (no child window) and shares frames over a session-local named file mapping.
- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id. The experimental frame-copy engine has no window embedding at all (offscreen EGL into a renderer canvas) and therefore works under native Wayland — dev builds only for now.
@@ -102,6 +102,37 @@ const embeddedMpvWin32Source = fs.readFileSync(
);
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
function writeWindowsHelperFixture(helperPath, importedDllName) {
const peOffset = 0x80;
const optionalHeaderOffset = peOffset + 24;
const optionalHeaderSize = 0xf0;
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
const importRva = 0x1000;
const importRawOffset = 0x200;
const importNameOffset = 0x30;
const image = Buffer.alloc(0x400);
image.write('MZ', 0, 'ascii');
image.writeUInt32LE(peOffset, 0x3c);
image.write('PE\0\0', peOffset, 'ascii');
image.writeUInt16LE(0x8664, peOffset + 4);
image.writeUInt16LE(1, peOffset + 6);
image.writeUInt16LE(optionalHeaderSize, peOffset + 20);
image.writeUInt16LE(0x20b, optionalHeaderOffset);
image.writeUInt32LE(0x200, optionalHeaderOffset + 60);
image.writeUInt32LE(16, optionalHeaderOffset + 108);
image.writeUInt32LE(importRva, optionalHeaderOffset + 120);
image.writeUInt32LE(40, optionalHeaderOffset + 124);
image.write('.idata\0\0', sectionTableOffset, 'ascii');
image.writeUInt32LE(0x200, sectionTableOffset + 8);
image.writeUInt32LE(importRva, sectionTableOffset + 12);
image.writeUInt32LE(0x200, sectionTableOffset + 16);
image.writeUInt32LE(importRawOffset, sectionTableOffset + 20);
image.writeUInt32LE(importRva + importNameOffset, importRawOffset + 12);
image.write(`${importedDllName}\0`, importRawOffset + importNameOffset);
fs.writeFileSync(helperPath, image);
}
test('Linux package identity does not expose the internal Electron backend project name', () => {
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
assert.equal(electronBuilderConfig.extraMetadata?.name, 'iptvnator');
@@ -119,8 +150,10 @@ test('Linux package identity does not expose the internal Electron backend proje
});
test('GitHub Releases auto-update metadata is generated and uploaded', () => {
// \r?\n keeps this host-agnostic: Windows checkouts with autocrlf see
// CRLF in the workflow file.
const releaseFiles = buildAndMakeWorkflow.match(
/files: \|\n([\s\S]*?)\n\s+env:/
/files: \|\r?\n([\s\S]*?)\r?\n\s+env:/
)?.[1];
assert.ok(releaseFiles, 'release upload files block must exist');
@@ -304,10 +337,16 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
for (const [platform, runtimeFile] of [
['windows', 'mpv-2.dll'],
['windows', 'libmpv-2.dll'],
['windows', join('lib', 'mpv.dll')],
['windows', join('lib', 'libmpv.dll')],
['windows', 'mpv.dll'],
['windows', 'libmpv.dll'],
]) {
const resourceDir = join(tempDir, platform);
// One fixture dir per runtime-file scenario: the frame-copy
// artifacts written below must not leak into the next
// iteration's missing-artifact assertions.
const resourceDir = join(
tempDir,
`${platform}-${runtimeFile.replace(/[\\/]/g, '_')}`
);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
@@ -322,6 +361,32 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
);
fs.writeFileSync(join(nativeDir, runtimeFile), '');
// Windows packages that ship the addon must also ship the
// frame-copy engine artifacts built by the same binding.gyp run.
const missingWindowsFrameCopyErrors = validatePackagedEmbeddedMpv(
resourceDir,
{ platform, required: true }
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('iptvnator_mpv_helper.exe')
)
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('embedded_mpv_frame_reader.node')
)
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
assert.deepEqual(
validatePackagedEmbeddedMpv(resourceDir, {
platform,
@@ -371,10 +436,10 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
// Host-agnostic assertion: on non-macOS hosts the validator also
// reports that link validation needs a macOS host, so only the
// frame-copy artifact requirement is asserted here.
const remainingErrors = validatePackagedEmbeddedMpv(
darwinResourceDir,
{ platform: 'darwin', required: true }
);
const remainingErrors = validatePackagedEmbeddedMpv(darwinResourceDir, {
platform: 'darwin',
required: true,
});
assert.ok(
!remainingErrors.some((error) =>
error.includes('frame-copy artifact')
@@ -408,6 +473,138 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
}
});
test('Windows frame-copy packages reject an mpv DLL that exists only under native/lib', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
for (const runtimeFile of [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
]) {
const resourceDir = join(tempDir, runtimeFile);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
fs.writeFileSync(join(nativeDir, 'lib', runtimeFile), '');
const errors = validatePackagedEmbeddedMpv(resourceDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes(
'beside the Windows frame-copy helper'
) && error.includes(nativeDir)
),
`${runtimeFile} under native/lib must not satisfy the helper DLL requirement: ${errors.join('; ')}`
);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy packages require the DLL imported by the helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
'mpv-2.dll'
);
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'libmpv.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes('imports mpv-2.dll') &&
error.includes(join(nativeDir, 'mpv-2.dll'))
),
`a different accepted DLL must not satisfy the helper import: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy package validation fails closed for a malformed helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), 'MZ');
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'mpv-2.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some((error) =>
error.includes(
'Unable to inspect Windows frame-copy helper imports'
)
),
`malformed helper must fail package validation: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows embedded MPV staging preserves import-library DLL basenames', () => {
assert.match(
embeddedMpvStageRuntimeSource,
@@ -472,10 +669,7 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
try {
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
const windowsHelperPath = join(
tempDir,
'iptvnator_mpv_helper.exe'
);
const windowsHelperPath = join(tempDir, 'iptvnator_mpv_helper.exe');
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
fs.writeFileSync(helperPath, '#!/bin/sh\n');
fs.chmodSync(helperPath, 0o644);
+267 -15
View File
@@ -4,6 +4,12 @@ const { spawnSync } = require('child_process');
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
const windowsMpvRuntimeNames = [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -67,6 +73,177 @@ function listRuntimeFiles(directoryPath) {
.sort();
}
function assertPeRange(image, offset, size, label) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size < 0 ||
offset + size > image.length
) {
throw new Error(`Invalid PE image: ${label} is out of range.`);
}
}
function readPeImportedDllNames(binaryPath) {
const image = fs.readFileSync(binaryPath);
assertPeRange(image, 0, 0x40, 'DOS header');
if (image.toString('ascii', 0, 2) !== 'MZ') {
throw new Error('Invalid PE image: missing DOS signature.');
}
const peOffset = image.readUInt32LE(0x3c);
assertPeRange(image, peOffset, 24, 'PE header');
if (image.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error('Invalid PE image: missing PE signature.');
}
const sectionCount = image.readUInt16LE(peOffset + 6);
const optionalHeaderSize = image.readUInt16LE(peOffset + 20);
const optionalHeaderOffset = peOffset + 24;
assertPeRange(
image,
optionalHeaderOffset,
optionalHeaderSize,
'optional header'
);
const optionalMagic = image.readUInt16LE(optionalHeaderOffset);
const dataDirectoryOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 112
: optionalMagic === 0x10b
? optionalHeaderOffset + 96
: null;
const directoryCountOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 108
: optionalMagic === 0x10b
? optionalHeaderOffset + 92
: null;
if (dataDirectoryOffset === null || directoryCountOffset === null) {
throw new Error(
`Invalid PE image: unsupported optional-header magic 0x${optionalMagic.toString(16)}.`
);
}
const optionalHeaderEnd = optionalHeaderOffset + optionalHeaderSize;
if (
directoryCountOffset + 4 > optionalHeaderEnd ||
dataDirectoryOffset + 16 > optionalHeaderEnd
) {
throw new Error(
'Invalid PE image: data directories exceed the optional header.'
);
}
assertPeRange(image, directoryCountOffset, 4, 'data-directory count');
if (image.readUInt32LE(directoryCountOffset) < 2) {
return [];
}
assertPeRange(image, dataDirectoryOffset + 8, 8, 'import directory');
const importRva = image.readUInt32LE(dataDirectoryOffset + 8);
const importSize = image.readUInt32LE(dataDirectoryOffset + 12);
if (importRva === 0 && importSize === 0) {
return [];
}
if (importRva === 0 || importSize < 20) {
throw new Error('Invalid PE image: malformed import directory.');
}
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
assertPeRange(
image,
sectionTableOffset,
sectionCount * 40,
'section table'
);
const sections = Array.from({ length: sectionCount }, (_, index) => {
const offset = sectionTableOffset + index * 40;
return {
virtualSize: image.readUInt32LE(offset + 8),
virtualAddress: image.readUInt32LE(offset + 12),
rawSize: image.readUInt32LE(offset + 16),
rawOffset: image.readUInt32LE(offset + 20),
};
});
const rvaToLocation = (rva, label) => {
const section = sections.find(
(candidate) =>
rva >= candidate.virtualAddress &&
rva <
candidate.virtualAddress +
Math.max(candidate.virtualSize, candidate.rawSize)
);
if (!section) {
throw new Error(
`Invalid PE image: ${label} RVA 0x${rva.toString(16)} is not mapped.`
);
}
const sectionOffset = rva - section.virtualAddress;
if (sectionOffset >= section.rawSize) {
throw new Error(
`Invalid PE image: ${label} is outside section file data.`
);
}
const offset = section.rawOffset + sectionOffset;
assertPeRange(image, offset, 1, label);
return {
offset,
sectionEnd: section.rawOffset + section.rawSize,
};
};
const importedDllNames = [];
const descriptorCount = Math.floor(importSize / 20);
let foundTerminator = false;
for (let index = 0; index < descriptorCount; index += 1) {
const descriptorLocation = rvaToLocation(
importRva + index * 20,
'import descriptor'
);
const descriptorOffset = descriptorLocation.offset;
if (descriptorOffset + 20 > descriptorLocation.sectionEnd) {
throw new Error(
'Invalid PE image: import descriptor crosses section file data.'
);
}
assertPeRange(image, descriptorOffset, 20, 'import descriptor');
const descriptor = image.subarray(
descriptorOffset,
descriptorOffset + 20
);
if (descriptor.every((value) => value === 0)) {
foundTerminator = true;
break;
}
const nameRva = image.readUInt32LE(descriptorOffset + 12);
const nameLocation = rvaToLocation(nameRva, 'imported DLL name');
const nameOffset = nameLocation.offset;
const nameEnd = image.indexOf(0, nameOffset);
if (nameEnd < 0 || nameEnd >= nameLocation.sectionEnd) {
throw new Error(
'Invalid PE image: imported DLL name is not terminated.'
);
}
const name = image.toString('ascii', nameOffset, nameEnd);
if (!name) {
throw new Error('Invalid PE image: imported DLL name is empty.');
}
importedDllNames.push(name);
}
if (!foundTerminator) {
throw new Error(
'Invalid PE image: import descriptor table has no terminator.'
);
}
return [...new Set(importedDllNames)];
}
function parseOtoolDependencies(binaryPath) {
const output = run('otool', ['-L', binaryPath]);
return output
@@ -426,16 +603,12 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
path.join(libDir, 'libmpv.dylib'),
];
case 'win32':
return [
nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'mpv.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv.dll') : null,
path.join(libDir, 'mpv-2.dll'),
path.join(libDir, 'libmpv-2.dll'),
path.join(libDir, 'mpv.dll'),
path.join(libDir, 'libmpv.dll'),
].filter(Boolean);
return windowsMpvRuntimeNames.flatMap((name) =>
[
nativeDir ? path.join(nativeDir, name) : null,
path.join(libDir, name),
].filter(Boolean)
);
case 'linux':
return [];
default:
@@ -480,7 +653,11 @@ function getEmbeddedMpvAddonArch(env = process.env) {
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
*/
function isForeignLinuxEmbeddedMpvArch(platform, targetArch, env = process.env) {
function isForeignLinuxEmbeddedMpvArch(
platform,
targetArch,
env = process.env
) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
@@ -540,12 +717,16 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
return errors;
}
if (platform === 'darwin') {
if (platform === 'darwin' || platform === 'win32') {
// The frame-copy engine artifacts are built by the same binding.gyp
// run as the addon; a macOS package that ships the addon without
// them would silently lose the engine (support probe hides it).
// run as the addon; a macOS/Windows package that ships the addon
// without them would silently lose the engine (support probe hides
// it). Linux packages intentionally strip the helper until the
// bundled-libmpv runtime lands (see electron-after-pack.cjs).
const missingFrameCopyArtifacts = [
'iptvnator_mpv_helper',
platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper',
'embedded_mpv_frame_reader.node',
]
.map((name) => path.join(unpackedNativeDir, name))
@@ -556,6 +737,77 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
)
);
if (platform === 'win32') {
// The helper is a separate executable. Windows resolves its
// imported libmpv DLL from the executable directory, so a copy
// under native/lib may satisfy addon bookkeeping but cannot
// start iptvnator_mpv_helper.exe.
const helperPath = path.join(
unpackedNativeDir,
'iptvnator_mpv_helper.exe'
);
const helperRuntimeCandidates = windowsMpvRuntimeNames.map((name) =>
path.join(unpackedNativeDir, name)
);
if (
!helperRuntimeCandidates.some((candidate) =>
fs.existsSync(candidate)
)
) {
errors.push(
[
`Missing bundled MPV DLL beside the Windows frame-copy helper in ${unpackedNativeDir}.`,
'Expected one of:',
...helperRuntimeCandidates.map(
(candidate) => `- ${candidate}`
),
].join('\n')
);
}
if (fs.existsSync(helperPath)) {
try {
const acceptedRuntimeNames = new Set(
windowsMpvRuntimeNames.map((name) => name.toLowerCase())
);
const importedRuntimeNames = readPeImportedDllNames(
helperPath
).filter((name) =>
acceptedRuntimeNames.has(name.toLowerCase())
);
if (importedRuntimeNames.length === 0) {
errors.push(
`Windows frame-copy helper does not import a supported MPV DLL: ${helperPath}`
);
}
const packagedRuntimeNames = new Set(
fs
.readdirSync(unpackedNativeDir, {
withFileTypes: true,
})
.filter((entry) => entry.isFile())
.map((entry) => entry.name.toLowerCase())
);
for (const importedRuntimeName of importedRuntimeNames) {
if (
!packagedRuntimeNames.has(
importedRuntimeName.toLowerCase()
)
) {
errors.push(
`Windows frame-copy helper imports ${importedRuntimeName}, but the matching DLL is missing beside it: ${path.join(unpackedNativeDir, importedRuntimeName)}`
);
}
}
} catch (error) {
errors.push(
`Unable to inspect Windows frame-copy helper imports at ${helperPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
}
if (!fs.existsSync(manifestPath)) {