mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
7d75d989e8
commit
59e08fd2d6
41 files changed
+1284
-157
No files matched your search
@@ -323,6 +323,12 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
win32)
|
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
|
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)
|
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.'); }"
|
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.'); }"
|
||||||
|
|||||||
@@ -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
|
- Built-in HTML5 player with HLS.js or Video.js
|
||||||
- External players: MPV, VLC (via IPC to Electron backend)
|
- 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 (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)**:
|
**VOD/Series Detail Pages (two-state layout)**:
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,23 @@
|
|||||||
],
|
],
|
||||||
"include_dirs": [
|
"include_dirs": [
|
||||||
"helper"
|
"helper"
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
[
|
||||||
|
"OS==\"win\"",
|
||||||
|
{
|
||||||
|
"defines": [
|
||||||
|
"WIN32_LEAN_AND_MEAN",
|
||||||
|
"NOMINMAX"
|
||||||
|
],
|
||||||
|
"msvs_settings": {
|
||||||
|
"VCCLCompilerTool": {
|
||||||
|
"CompileAs": 2,
|
||||||
|
"ExceptionHandling": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +170,34 @@
|
|||||||
"-ldl"
|
"-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
|
* core symbols from that linked library and falls back to eglGetProcAddress
|
||||||
* for extensions.
|
* 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
|
* Threading contract: create() runs on the main thread and must leave the
|
||||||
* context unbound; the render thread calls makeCurrent() once and owns the
|
* context unbound; the render thread calls makeCurrent() once and owns the
|
||||||
* context until destroy().
|
* context until destroy().
|
||||||
@@ -59,6 +66,59 @@
|
|||||||
#define EGL_PLATFORM_GBM_KHR 0x31D7
|
#define EGL_PLATFORM_GBM_KHR 0x31D7
|
||||||
#endif
|
#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
|
#endif
|
||||||
|
|
||||||
namespace frame_helper {
|
namespace frame_helper {
|
||||||
@@ -512,6 +572,222 @@ private:
|
|||||||
std::string renderer_;
|
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
|
#else
|
||||||
#error "frame_helper_gl.h has no GL context implementation for this platform"
|
#error "frame_helper_gl.h has no GL context implementation for this platform"
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
#include <mpv/client.h>
|
#include <mpv/client.h>
|
||||||
#include <mpv/render_gl.h>
|
#include <mpv/render_gl.h>
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -39,19 +41,47 @@ struct ShmRing {
|
|||||||
uint8_t* base = nullptr;
|
uint8_t* base = nullptr;
|
||||||
size_t size = 0;
|
size_t size = 0;
|
||||||
std::string name;
|
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,
|
bool create(const std::string& shmName, int width, int height,
|
||||||
uint32_t generation) {
|
uint32_t generation) {
|
||||||
destroy();
|
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 frameBytes = (uint64_t)width * 4u * (uint64_t)height;
|
||||||
const uint64_t dataOffset =
|
const uint64_t dataOffset =
|
||||||
(sizeof(FrameShmHeader) + FRAME_SHM_DATA_ALIGN - 1) &
|
(sizeof(FrameShmHeader) + FRAME_SHM_DATA_ALIGN - 1) &
|
||||||
~(uint64_t)(FRAME_SHM_DATA_ALIGN - 1);
|
~(uint64_t)(FRAME_SHM_DATA_ALIGN - 1);
|
||||||
const size_t total =
|
const size_t total =
|
||||||
(size_t)(dataOffset + FRAME_SHM_RING_SLOTS * frameBytes);
|
(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) {
|
if (ftruncate(fd, (off_t)total) != 0) {
|
||||||
close(fd);
|
close(fd);
|
||||||
shm_unlink(shmName.c_str());
|
shm_unlink(shmName.c_str());
|
||||||
@@ -64,6 +94,7 @@ struct ShmRing {
|
|||||||
shm_unlink(shmName.c_str());
|
shm_unlink(shmName.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
std::memset(mapped, 0, sizeof(FrameShmHeader));
|
std::memset(mapped, 0, sizeof(FrameShmHeader));
|
||||||
auto* hdr = static_cast<FrameShmHeader*>(mapped);
|
auto* hdr = static_cast<FrameShmHeader*>(mapped);
|
||||||
hdr->version = FRAME_SHM_VERSION;
|
hdr->version = FRAME_SHM_VERSION;
|
||||||
@@ -89,10 +120,16 @@ struct ShmRing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void destroy() {
|
void destroy() {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
if (base) UnmapViewOfFile(base);
|
||||||
|
if (mapping) CloseHandle(mapping);
|
||||||
|
mapping = nullptr;
|
||||||
|
#else
|
||||||
if (base) {
|
if (base) {
|
||||||
munmap(base, size);
|
munmap(base, size);
|
||||||
shm_unlink(name.c_str());
|
shm_unlink(name.c_str());
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
header = nullptr;
|
header = nullptr;
|
||||||
base = nullptr;
|
base = nullptr;
|
||||||
size = 0;
|
size = 0;
|
||||||
|
|||||||
@@ -6,14 +6,29 @@
|
|||||||
* resize creates a fresh shm segment named `<base>-g<generation>` and the
|
* resize creates a fresh shm segment named `<base>-g<generation>` and the
|
||||||
* reader re-attaches when the helper announces the new generation.
|
* reader re-attaches when the helper announces the new generation.
|
||||||
*
|
*
|
||||||
* Must compile as C11 (reader addon) and C++17 (helper). Both consumers
|
* Must compile as C11 (reader addon) and C++17 (helper) on POSIX; on
|
||||||
* build with node-gyp's GNU dialects; frame_shm_now_ns() relies on POSIX
|
* Windows both consumers build as C++ (MSVC's C mode lacks <stdatomic.h>).
|
||||||
* clock_gettime, which strict -std=c11 (__STRICT_ANSI__) would hide.
|
* 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
|
#pragma once
|
||||||
|
|
||||||
#include <stdint.h>
|
#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>
|
#include <time.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
#include <atomic>
|
#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
|
/* Monotonic clock for produce_time_ns/heartbeat_ns. Producer (helper) and
|
||||||
* consumer (reader addon) MUST use this same clock so age math stays valid.
|
* 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
|
* POSIX: CLOCK_MONOTONIC (macOS 10.12+, Linux). Windows: QPC, scaled to ns
|
||||||
* port will need a QueryPerformanceCounter shim here. */
|
* without overflow by splitting whole seconds from the remainder. */
|
||||||
static inline uint64_t frame_shm_now_ns(void) {
|
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;
|
struct timespec ts;
|
||||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
|
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_MAGIC 0x564d5046u /* 'FPMV' */
|
||||||
#define FRAME_SHM_VERSION 1u
|
#define FRAME_SHM_VERSION 1u
|
||||||
#define FRAME_SHM_RING_SLOTS 3u
|
#define FRAME_SHM_RING_SLOTS 3u
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* iptvnator-mpv-helper — frame-copy embedded MPV helper process
|
* 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,
|
* One process = one playback session. Owns libmpv end to end: decodes,
|
||||||
* renders offscreen at viewport size, publishes BGRA frames into a shared
|
* renders offscreen at viewport size, publishes BGRA frames into a shared
|
||||||
@@ -101,7 +101,11 @@ std::string isoTimestampNow() {
|
|||||||
char buffer[32];
|
char buffer[32];
|
||||||
const time_t now = time(nullptr);
|
const time_t now = time(nullptr);
|
||||||
struct tm utc;
|
struct tm utc;
|
||||||
|
#if defined(_WIN32)
|
||||||
|
gmtime_s(&utc, &now);
|
||||||
|
#else
|
||||||
gmtime_r(&now, &utc);
|
gmtime_r(&now, &utc);
|
||||||
|
#endif
|
||||||
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
|
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
@@ -633,7 +637,9 @@ HelperArgs parseArgs(int argc, char** argv) {
|
|||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
std::setlocale(LC_NUMERIC, "C");
|
std::setlocale(LC_NUMERIC, "C");
|
||||||
|
#if !defined(_WIN32)
|
||||||
signal(SIGPIPE, SIG_IGN);
|
signal(SIGPIPE, SIG_IGN);
|
||||||
|
#endif
|
||||||
const HelperArgs args = parseArgs(argc, argv);
|
const HelperArgs args = parseArgs(argc, argv);
|
||||||
|
|
||||||
g_state.mpv = mpv_create();
|
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(),
|
mpv_render_context_set_update_callback(g_state.pipeline.renderContext(),
|
||||||
onRenderUpdate, &g_state.pipeline);
|
onRenderUpdate, &g_state.pipeline);
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
const double helperPid = (double)GetCurrentProcessId();
|
||||||
|
#else
|
||||||
|
const double helperPid = (double)getpid();
|
||||||
|
#endif
|
||||||
emitLine(JsonWriter()
|
emitLine(JsonWriter()
|
||||||
.str("event", "hello")
|
.str("event", "hello")
|
||||||
.num("pid", (double)getpid())
|
.num("pid", helperPid)
|
||||||
.num("protocolVersion", 1)
|
.num("protocolVersion", 1)
|
||||||
.finish());
|
.finish());
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,30 @@
|
|||||||
* memory cage forbids external ArrayBuffers over foreign memory.
|
* memory cage forbids external ArrayBuffers over foreign memory.
|
||||||
*
|
*
|
||||||
* Plain C N-API (no node-addon-api) so it stays ABI-stable and trivial.
|
* 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;
|
* macOS and Linux (POSIX shm) plus Windows (named file mapping; binding.gyp
|
||||||
* the TypeScript side gates on platform before requiring it.
|
* 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
|
#define NAPI_VERSION 8
|
||||||
#include <node_api.h>
|
#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 <fcntl.h>
|
||||||
#include <stdatomic.h>
|
#include <stdatomic.h>
|
||||||
@@ -26,6 +43,8 @@
|
|||||||
|
|
||||||
#include "../helper/frame_shm.h"
|
#include "../helper/frame_shm.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
static FrameShmHeader* g_header = NULL;
|
static FrameShmHeader* g_header = NULL;
|
||||||
static uint8_t* g_base = NULL;
|
static uint8_t* g_base = NULL;
|
||||||
static size_t g_size = 0;
|
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) {
|
static void unmap_current(void) {
|
||||||
if (g_base) {
|
if (g_base) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
UnmapViewOfFile(g_base);
|
||||||
|
#else
|
||||||
munmap(g_base, g_size);
|
munmap(g_base, g_size);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
g_base = NULL;
|
g_base = NULL;
|
||||||
g_header = 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");
|
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, ®ion, 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);
|
const int fd = shm_open(name, O_RDONLY, 0);
|
||||||
if (fd < 0) return throw_error(env, "shm_open failed (helper gone?)");
|
if (fd < 0) return throw_error(env, "shm_open failed (helper gone?)");
|
||||||
struct stat segment;
|
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);
|
mmap(NULL, (size_t)segment.st_size, PROT_READ, MAP_SHARED, fd, 0);
|
||||||
close(fd);
|
close(fd);
|
||||||
if (mapped == MAP_FAILED) return throw_error(env, "mmap failed");
|
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;
|
FrameShmHeader* header = (FrameShmHeader*)mapped;
|
||||||
if (header->magic != FRAME_SHM_MAGIC ||
|
if (header->magic != FRAME_SHM_MAGIC ||
|
||||||
header->version != FRAME_SHM_VERSION) {
|
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");
|
return throw_error(env, "frame ring not initialized yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
unmap_current();
|
unmap_current();
|
||||||
g_base = (uint8_t*)mapped;
|
g_base = (uint8_t*)mapped;
|
||||||
g_size = (size_t)segment.st_size;
|
g_size = segment_size;
|
||||||
g_header = header;
|
g_header = header;
|
||||||
|
|
||||||
napi_value result;
|
napi_value result;
|
||||||
@@ -204,7 +250,7 @@ static napi_value Init(napi_env env, napi_value exports) {
|
|||||||
return 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) {
|
static napi_value Init(napi_env env, napi_value exports) {
|
||||||
/* Frame-copy is not ported to this platform yet; loading this module
|
/* Frame-copy is not ported to this platform yet; loading this module
|
||||||
|
|||||||
@@ -52,7 +52,16 @@
|
|||||||
".deps/**",
|
".deps/**",
|
||||||
"obj.target/**",
|
"obj.target/**",
|
||||||
"**/.deps/**",
|
"**/.deps/**",
|
||||||
"**/obj.target/**"
|
"**/obj.target/**",
|
||||||
|
"obj/**",
|
||||||
|
"**/obj/**",
|
||||||
|
"**/*.pdb",
|
||||||
|
"**/*.iobj",
|
||||||
|
"**/*.ipdb",
|
||||||
|
"**/*.ilk",
|
||||||
|
"**/*.exp",
|
||||||
|
"**/*.lib",
|
||||||
|
"**/*.dll.a"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -105,7 +114,16 @@
|
|||||||
".deps/**",
|
".deps/**",
|
||||||
"obj.target/**",
|
"obj.target/**",
|
||||||
"**/.deps/**",
|
"**/.deps/**",
|
||||||
"**/obj.target/**"
|
"**/obj.target/**",
|
||||||
|
"obj/**",
|
||||||
|
"**/obj/**",
|
||||||
|
"**/*.pdb",
|
||||||
|
"**/*.iobj",
|
||||||
|
"**/*.ipdb",
|
||||||
|
"**/*.ilk",
|
||||||
|
"**/*.exp",
|
||||||
|
"**/*.lib",
|
||||||
|
"**/*.dll.a"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+10
-3
@@ -32,7 +32,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
|||||||
['darwin', 'x64', false],
|
['darwin', 'x64', false],
|
||||||
['linux', 'x64', true],
|
['linux', 'x64', true],
|
||||||
['linux', 'arm64', true],
|
['linux', 'arm64', true],
|
||||||
['win32', 'x64', false],
|
['win32', 'x64', true],
|
||||||
|
['freebsd', 'x64', false],
|
||||||
])('%s/%s -> %s', (platform, arch, expected) => {
|
])('%s/%s -> %s', (platform, arch, expected) => {
|
||||||
Object.defineProperty(process, 'platform', { value: platform });
|
Object.defineProperty(process, 'platform', { value: platform });
|
||||||
Object.defineProperty(process, 'arch', { value: arch });
|
Object.defineProperty(process, 'arch', { value: arch });
|
||||||
@@ -54,8 +55,14 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
|||||||
'build',
|
'build',
|
||||||
'Release'
|
'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 = () =>
|
const helperPath = () =>
|
||||||
path.join(releaseDir(), 'iptvnator_mpv_helper');
|
path.join(releaseDir(), helperFileName());
|
||||||
const readerPath = () =>
|
const readerPath = () =>
|
||||||
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
|
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
|||||||
mkdirSync(packagedNativeDir, { recursive: true });
|
mkdirSync(packagedNativeDir, { recursive: true });
|
||||||
const packagedHelper = path.join(
|
const packagedHelper = path.join(
|
||||||
packagedNativeDir,
|
packagedNativeDir,
|
||||||
'iptvnator_mpv_helper'
|
helperFileName()
|
||||||
);
|
);
|
||||||
writeFileSync(packagedHelper, '#!/bin/sh\n');
|
writeFileSync(packagedHelper, '#!/bin/sh\n');
|
||||||
chmodSync(packagedHelper, 0o755);
|
chmodSync(packagedHelper, 0o755);
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import path from 'path';
|
|||||||
* sandbox and engine decisions cannot drift. These helpers must remain
|
* sandbox and engine decisions cannot drift. These helpers must remain
|
||||||
* callable before app.whenReady().
|
* callable before app.whenReady().
|
||||||
*
|
*
|
||||||
* macOS stays Apple-Silicon-only. Linux supports every architecture because
|
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
|
||||||
* its helper renders through headless EGL and links libmpv out of process.
|
* 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 {
|
export function isFrameCopyPlatformSupported(): boolean {
|
||||||
return (
|
return (
|
||||||
process.platform === 'linux' ||
|
process.platform === 'linux' ||
|
||||||
|
process.platform === 'win32' ||
|
||||||
(process.platform === 'darwin' && process.arch === 'arm64')
|
(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
|
* Resolve the first executable frame-copy helper with a readable regular-file
|
||||||
* frame reader beside it. Both artifacts are required before the engine may
|
* 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 {
|
export function resolveFrameCopyHelperPath(): string | null {
|
||||||
// Packaged startup must never fall through to cwd/dist development
|
// Packaged startup must never fall through to cwd/dist development
|
||||||
@@ -89,12 +95,15 @@ export function resolveFrameCopyHelperPath(): string | null {
|
|||||||
const addonCandidates = app.isPackaged
|
const addonCandidates = app.isPackaged
|
||||||
? getPackagedAddonPaths()
|
? getPackagedAddonPaths()
|
||||||
: [getLocalBuildAddonPath(), ...getDistAddonPaths()];
|
: [getLocalBuildAddonPath(), ...getDistAddonPaths()];
|
||||||
|
const helperFileName =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'iptvnator_mpv_helper.exe'
|
||||||
|
: 'iptvnator_mpv_helper';
|
||||||
return (
|
return (
|
||||||
addonCandidates
|
addonCandidates
|
||||||
.map((candidatePath) => path.dirname(candidatePath))
|
.map((candidatePath) => path.dirname(candidatePath))
|
||||||
.map((nativeDir) => ({
|
.map((nativeDir) => ({
|
||||||
helper: path.join(nativeDir, 'iptvnator_mpv_helper'),
|
helper: path.join(nativeDir, helperFileName),
|
||||||
reader: path.join(
|
reader: path.join(
|
||||||
nativeDir,
|
nativeDir,
|
||||||
'embedded_mpv_frame_reader.node'
|
'embedded_mpv_frame_reader.node'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
const spawnMock = jest.fn();
|
const spawnMock = jest.fn();
|
||||||
jest.mock('child_process', () => ({
|
jest.mock('child_process', () => ({
|
||||||
@@ -117,8 +118,10 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
|||||||
expect(frameSourceChanges).toEqual([
|
expect(frameSourceChanges).toEqual([
|
||||||
{ sessionId, shmName: `/${sessionId}-g1` },
|
{ 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(
|
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],
|
['darwin', 'x64', false],
|
||||||
['linux', 'x64', true],
|
['linux', 'x64', true],
|
||||||
['linux', 'arm64', true],
|
['linux', 'arm64', true],
|
||||||
['win32', 'x64', false],
|
['win32', 'x64', true],
|
||||||
|
['freebsd', 'x64', false],
|
||||||
])(
|
])(
|
||||||
'on %s/%s with a helper binary present -> %s',
|
'on %s/%s with a helper binary present -> %s',
|
||||||
(platform, arch, expected) => {
|
(platform, arch, expected) => {
|
||||||
|
|||||||
@@ -40,19 +40,23 @@ describe('Embedded MPV native source recording invariants', () => {
|
|||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
const frameHelperRenderSource = readFileSync(
|
const frameHelperRenderSource = readFileSync(
|
||||||
path.resolve(
|
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
|
||||||
__dirname,
|
|
||||||
'../../../native/helper/frame_helper_render.h'
|
|
||||||
),
|
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
const frameHelperGlSource = readFileSync(
|
const frameHelperGlSource = readFileSync(
|
||||||
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
|
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
const frameShmSource = readFileSync(
|
||||||
|
path.resolve(__dirname, '../../../native/helper/frame_shm.h'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
|
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
|
||||||
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
|
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
|
||||||
);
|
);
|
||||||
|
const windowsFrameHelperGlSource = frameHelperGlSource.slice(
|
||||||
|
frameHelperGlSource.indexOf('#define FRAME_HELPER_WGL_ENTRY_POINTS')
|
||||||
|
);
|
||||||
|
|
||||||
function functionBody(name: string): string {
|
function functionBody(name: string): string {
|
||||||
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
|
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', () => {
|
it('copies Windows runtime DLLs next to the addon for Windows loader lookup', () => {
|
||||||
expect(buildScriptSource).toContain(
|
expect(buildScriptSource).toContain("'libmpv-2.dll',");
|
||||||
"'libmpv-2.dll',"
|
|
||||||
);
|
|
||||||
expect(buildScriptSource).toContain(
|
expect(buildScriptSource).toContain(
|
||||||
'path.join(outputDir, windowsDllName)'
|
'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', () => {
|
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
|
||||||
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
|
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
|
||||||
'dlsym(RTLD_DEFAULT, name)'
|
'dlsym(RTLD_DEFAULT, name)'
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ export class EmbeddedMpvNativeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
||||||
* Experimental, macOS Apple Silicon (owner decision 2026-07-10) and
|
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
|
||||||
* Linux, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on
|
* and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1
|
||||||
* top of the regular embedded MPV experiment flag.
|
* on top of the regular embedded MPV experiment flag.
|
||||||
*/
|
*/
|
||||||
private isFrameCopyEngineRequested(): boolean {
|
private isFrameCopyEngineRequested(): boolean {
|
||||||
return ['1', 'true', 'yes', 'on'].includes(
|
return ['1', 'true', 'yes', 'on'].includes(
|
||||||
@@ -215,9 +215,9 @@ export class EmbeddedMpvNativeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The frame-copy engine renders offscreen (headless EGL on Linux)
|
// The frame-copy engine renders offscreen (headless EGL on Linux,
|
||||||
// into a renderer canvas: the Linux X11/Xwayland and system-mpv
|
// WGL on Windows) into a renderer canvas: the Linux X11/Xwayland and
|
||||||
// requirements below only bind the native --wid engine. Both
|
// system-mpv requirements below only bind the native --wid engine. Both
|
||||||
// native-engine failure returns still advertise frameCopyAvailable
|
// native-engine failure returns still advertise frameCopyAvailable
|
||||||
// so the Settings toggle stays reachable — otherwise the states the
|
// so the Settings toggle stays reachable — otherwise the states the
|
||||||
// frame-copy engine exists to fix would hide the way to enable it.
|
// frame-copy engine exists to fix would hide the way to enable it.
|
||||||
|
|||||||
@@ -184,7 +184,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {
|
@if (
|
||||||
|
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
|
||||||
|
) {
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-item__meta">
|
<div class="setting-item__meta">
|
||||||
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||||
@@ -256,7 +258,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {
|
@if (
|
||||||
|
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
|
||||||
|
) {
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-item__meta">
|
<div class="setting-item__meta">
|
||||||
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||||
|
|||||||
@@ -171,6 +171,30 @@ describe('SettingsPlaybackSectionComponent', () => {
|
|||||||
).toBeNull();
|
).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', () => {
|
it('shows the recording folder setting only in desktop builds', () => {
|
||||||
fixture.componentRef.setInput('isDesktop', true);
|
fixture.componentRef.setInput('isDesktop', true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -364,6 +388,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
|||||||
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
||||||
openStreamOnDoubleClick: new FormControl(false),
|
openStreamOnDoubleClick: new FormControl(false),
|
||||||
showExternalPlaybackBar: new FormControl(true),
|
showExternalPlaybackBar: new FormControl(true),
|
||||||
|
embeddedMpvFrameCopy: new FormControl(false),
|
||||||
mpvPlayerPath: new FormControl(''),
|
mpvPlayerPath: new FormControl(''),
|
||||||
mpvPlayerArguments: new FormControl(''),
|
mpvPlayerArguments: new FormControl(''),
|
||||||
mpvReuseInstance: new FormControl(false),
|
mpvReuseInstance: new FormControl(false),
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon وعلى Linux.",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon و Linux.",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
|
"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": "Пачне дзейнічаць пасля перазапуску праграмы."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
|
"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": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSおよびLinuxのみ。",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 및 Linux 전용.",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "Что нового",
|
"APP_UPDATE_RELEASE_NOTES": "Что нового",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
|
"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": "Вступит в силу после перезапуска приложения."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
|
"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."
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS 和 Linux。",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@
|
|||||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||||
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS 和 Linux。",
|
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
|
||||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
|
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
|
||||||
},
|
},
|
||||||
"THEMES": {
|
"THEMES": {
|
||||||
|
|||||||
@@ -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/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.
|
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
|
||||||
|
|
||||||
Frame-copy engine sources (experimental, macOS Apple Silicon and Linux —
|
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and
|
||||||
see the "Frame-Copy Engine" section below):
|
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/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.
|
- `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
|
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
|
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
|
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`.
|
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.
|
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
|
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
|
||||||
embedded MPV experiment flag) switches macOS/arm64 and Linux to a second
|
embedded MPV experiment flag) switches macOS/arm64, Linux and Windows to a
|
||||||
rendering engine that replaces the native-view compositing entirely
|
second rendering engine that replaces the native-view compositing entirely
|
||||||
(gate: `isFrameCopyPlatformSupported()` in
|
(gate: `isFrameCopyPlatformSupported()` in
|
||||||
`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the
|
`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the
|
||||||
service and the adapter):
|
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
|
context — `frame_helper_gl.h`: CGL on macOS; on Linux EGL, acquiring a
|
||||||
display in order surfaceless-Mesa → default display → GBM render node;
|
display in order surfaceless-Mesa → default display → GBM render node;
|
||||||
every tier must complete config/context/bind validation, hardware rendering
|
every tier must complete config/context/bind validation, hardware rendering
|
||||||
is preferred, and a software tier is retained only as the final fallback),
|
is preferred, and a software tier is retained only as the final fallback;
|
||||||
publishes BGRA frames into a POSIX
|
on Windows WGL against a hidden window, bootstrapping a 3.2 core context
|
||||||
shm seqlock ring
|
through `wglCreateContextAttribsARB`), publishes BGRA frames into a shm
|
||||||
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
|
seqlock ring (`frame_shm.h`, 3 slots, resize creates a new `-g<N>`
|
||||||
plays audio directly. Control protocol: tab-separated commands on stdin,
|
generation — POSIX shm on macOS/Linux, a `Local\` named file mapping on
|
||||||
JSON events on stdout; the `snapshot` event mirrors
|
Windows; the protocol carries POSIX-style `/impv-*` names everywhere and
|
||||||
`NativeEmbeddedMpvSessionSnapshot`. Status semantics are ported from
|
the native sides derive the mapping name), and plays audio directly.
|
||||||
`embedded_mpv.mm`.
|
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` —
|
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` —
|
||||||
implements the same `NativeEmbeddedMpvAddon` surface over the helper
|
implements the same `NativeEmbeddedMpvAddon` surface over the helper
|
||||||
process, so `EmbeddedMpvNativeService` (polling, diffing, power blocker,
|
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
|
session — Angular teardown never runs on a renderer crash/hard reload, and
|
||||||
without the watch helper processes (or native mpv handles) would leak until
|
without the watch helper processes (or native mpv handles) would leak until
|
||||||
app shutdown. Unexpected helper exits surface as a session `error`. macOS
|
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
|
`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
|
ships; on Windows the bundled mpv DLL is also copied beside the helper so
|
||||||
asset copy, and optional/skipped native rebuilds remove stale helper/reader
|
the executable resolves it from its own directory. The after-pack hook
|
||||||
artifacts before reporting frame-copy availability. This cleanup prevents
|
restores the POSIX helper's executable mode after the asset copy, and
|
||||||
known leftover build output; it is not a compatibility check for a complete
|
optional/skipped native rebuilds remove stale helper/reader artifacts before
|
||||||
but version-mismatched runtime pair. Linux packages deliberately do NOT ship
|
reporting frame-copy availability. This cleanup prevents known leftover build
|
||||||
the helper yet: it links the build host's system `libmpv`, which packaged
|
output; it is not a compatibility check for a complete but version-mismatched
|
||||||
apps cannot assume is installed, so the centralized after-pack artifact
|
runtime pair. Linux packages deliberately do NOT ship the helper yet: it links
|
||||||
preparation strips both possible helper basenames and package validation
|
the build host's system `libmpv`, which packaged apps cannot assume is
|
||||||
rejects either one if it survives. The support probe therefore reports
|
installed, so centralized after-pack preparation strips both possible helper
|
||||||
frame-copy unavailable in Linux packages.
|
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
|
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
|
||||||
lands (PORTING.md milestone 4).
|
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
|
string to stderr. If an early tier selects Mesa software rendering (for
|
||||||
example, while a proprietary NVIDIA driver is reachable through the default
|
example, while a proprietary NVIDIA driver is reachable through the default
|
||||||
display or GBM), it probes the remaining tiers and uses software only when
|
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
|
no hardware-backed context works. Windows (any arch with a helper, in
|
||||||
future work — the shm protocol and adapter are platform-agnostic.
|
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
|
- 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
|
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.
|
produce-to-upload latency, zero torn frames over a 10-minute run.
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# Frame-copy engine — Windows/Linux porting handoff
|
# Frame-copy engine — Windows/Linux porting handoff
|
||||||
|
|
||||||
> Handoff for future Claude/dev sessions on Windows and Linux machines
|
> Historical handoff and maintenance notes for the completed Linux and
|
||||||
> (they won't have the originating Mac's local session memory — this file
|
> Windows ports. The canonical runtime contract now lives in
|
||||||
> is the transfer). Written 2026-07-11 by the macOS session that built the
|
> `docs/architecture/embedded-mpv-native.md`.
|
||||||
> engine; fold into DESIGN.md once both ports land.
|
|
||||||
|
|
||||||
## State as of 2026-07-15
|
## State as of 2026-07-15
|
||||||
|
|
||||||
@@ -19,12 +18,28 @@
|
|||||||
(Wayland session) with the xtream mock portal. Dev-build-only on Linux:
|
(Wayland session) with the xtream mock portal. Dev-build-only on Linux:
|
||||||
the helper links system libmpv and `electron-after-pack.cjs` strips it
|
the helper links system libmpv and `electron-after-pack.cjs` strips it
|
||||||
from packages until milestone 4 (Linux bundled-libmpv runtime) — remove
|
from packages until milestone 4 (Linux bundled-libmpv runtime) — remove
|
||||||
that strip when milestone 4 lands. The Windows implementation lives in the
|
that strip when milestone 4 lands.
|
||||||
follow-up PR #1175; until that PR lands, `master` still has no WGL/named-shm
|
- **The Windows port below landed through PR #1175** — WGL GlContext twin,
|
||||||
helper and the frame-copy flag falls back to the native engine on Windows.
|
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
|
- PR #1169 credits larsemig's idea (#1154 comment 4932807350). Shared-player
|
||||||
controls are reviewed as a separate integration after this platform stack;
|
controls remain a separate integration after this platform stack; do not
|
||||||
do not conflate that UI layer with the frame-copy transport ports.
|
conflate that UI layer with the frame-copy transport ports.
|
||||||
|
|
||||||
## What "porting" means
|
## 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.
|
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
|
├── mpv_frame_helper.cpp # portable: protocol, mpv session, snapshots
|
||||||
├── frame_helper_io.h # portable: TSV-in/JSON-out, percent-encoding
|
├── frame_helper_io.h # portable: TSV-in/JSON-out, percent-encoding
|
||||||
├── frame_shm.h # portable layout + shared CLOCK_MONOTONIC clock
|
├── frame_shm.h # portable layout + shared clock (POSIX
|
||||||
│ # (POSIX shm calls still need a Windows twin)
|
│ # CLOCK_MONOTONIC / Windows QPC) + the Windows
|
||||||
├── frame_helper_render.h # portable: FBO/PBO readback + shm publish
|
│ # Local\ mapping-name derivation
|
||||||
└── frame_helper_gl.h # PLATFORM SEAM: GlContext — CGL (macOS) and
|
├── frame_helper_render.h # portable render + ShmRing (POSIX shm_open /
|
||||||
# EGL (Linux); Windows adds its WGL twin HERE
|
│ # 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
|
apps/electron-backend/native/src/embedded_mpv_frame_reader.c
|
||||||
# real impl on __APPLE__ + __linux__, stub
|
# real impl on __APPLE__ + __linux__ + _WIN32
|
||||||
# elsewhere (Windows needs shm-open/clock twins)
|
# (compiled as C++ there), stub elsewhere
|
||||||
```
|
```
|
||||||
|
|
||||||
Porting Windows = give `frame_helper_gl.h` a WGL GlContext twin, give the
|
All three ports have landed; new platforms follow the same seams: a
|
||||||
shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
|
GlContext twin in `frame_helper_gl.h`, shm create/open + `frame_shm_now_ns`
|
||||||
`embedded-mpv-frame-copy-platform.util.ts`, extend packaging.
|
twins, the TS gate in `embedded-mpv-frame-copy-platform.util.ts`, packaging.
|
||||||
|
|
||||||
## Branching & merge strategy
|
## Branching & merge strategy
|
||||||
|
|
||||||
- Merge order is #1169 → #1171 → #1175. #1169 is already merged; #1171 is
|
- The platform stack landed in order #1169 → #1171 → #1175.
|
||||||
based directly on that `master`, while #1175 remains stacked on the Linux
|
- Each child was rewritten onto the latest squash-merged `master` using only
|
||||||
port until #1171 lands.
|
its platform-specific commit range; the old parent history was not replayed.
|
||||||
- Rewrite only the platform-specific commit range when moving a stacked PR;
|
- New frame-copy work branches directly from `master`. Keep future stacks at
|
||||||
do not replay the old parent history after its squash merge. Retarget the PR
|
most one unmerged level deep and land platform measurements with their port.
|
||||||
explicitly and keep the parent branch until its child has been rewritten.
|
|
||||||
- Keep the stack at most one unmerged level deep. New follow-up work branches
|
|
||||||
from the latest landed platform base on `master`.
|
|
||||||
- Commit incrementally within the port branch; land each platform's
|
|
||||||
measurement rows in `RESULTS.md` in the same PR as its port.
|
|
||||||
|
|
||||||
## Per-platform task lists
|
## 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`
|
the Linux support matrix in `docs/architecture/embedded-mpv-native.md`
|
||||||
when this lands.
|
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 +
|
1. **Render backend**: WGL headless — create a hidden message-only window +
|
||||||
dummy pixel format, `wglCreateContextAttribsARB` 3.2 core, then the same
|
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'); ..."`
|
`node -e "const r=require('.../embedded_mpv_frame_reader.node'); const i=r.open('/impv-t-g2'); ..."`
|
||||||
→ `latestSeq()` advancing + pixel min/max spread.
|
→ `latestSeq()` advancing + pixel min/max spread.
|
||||||
- **In-app**: `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 pnpm run
|
- **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
|
parallel instance for CDP testing: build, then run
|
||||||
`electron dist/apps/electron-backend/main.js --remote-debugging-port=9223
|
`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;
|
(dist package.json has no `main` field — point at main.js explicitly;
|
||||||
a separate user-data-dir avoids the Chromium profile singleton).
|
a separate user-data-dir avoids the Chromium profile singleton).
|
||||||
- **Perf gate**: follow `RESULTS.md` methodology (STATS/LONGRUN lines,
|
- **Perf gate**: follow `RESULTS.md` methodology (STATS/LONGRUN lines,
|
||||||
|
|||||||
@@ -143,6 +143,50 @@ Readings:
|
|||||||
- EGL display tier used: Mesa surfaceless platform (first tier; no display
|
- EGL display tier used: Mesa surfaceless platform (first tier; no display
|
||||||
server needed).
|
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).
|
||||||
@@ -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
|
* embedded_mpv_frame_reader addon to the announced shm generation, and
|
||||||
* reports producer fps, copy latency (ageMs), copy wall time, torn reads
|
* reports producer fps, copy latency (ageMs), copy wall time, torn reads
|
||||||
* and pixel spread. Usage:
|
* and pixel spread. Usage:
|
||||||
@@ -22,7 +22,12 @@ const hwdecIdx = rest.indexOf('--hwdec');
|
|||||||
const hwdec = hwdecIdx >= 0 ? rest[hwdecIdx + 1] : null;
|
const hwdec = hwdecIdx >= 0 ? rest[hwdecIdx + 1] : null;
|
||||||
|
|
||||||
const reader = require(path.join(releaseDir, 'embedded_mpv_frame_reader.node'));
|
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 shmBase = `/impv-probe-${process.pid}`;
|
||||||
|
|
||||||
const args = ['--shm-base', shmBase, '--width', width, '--height', height];
|
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));
|
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);
|
await sleep(300);
|
||||||
child.stdin.write(`load\turl=${url.replace(/%/g, '%25')}\n`);
|
child.stdin.write(`load\turl=${url.replace(/%/g, '%25')}\n`);
|
||||||
@@ -102,7 +114,7 @@ while (Date.now() - start < durationMs) {
|
|||||||
if (result.torn) torn += 1;
|
if (result.torn) torn += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await sleep(2);
|
await pollYield();
|
||||||
}
|
}
|
||||||
const elapsed = (Date.now() - start) / 1000;
|
const elapsed = (Date.now() - start) / 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Embedded MPV Runtime
|
# 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
|
## 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
|
(`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
|
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
|
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
|
`runtime-manifest.json` is missing, CI generates a minimal manifest from the
|
||||||
archive URL/path and checksum; release-ready runtime archives should still
|
archive URL/path and checksum; release-ready runtime archives should still
|
||||||
provide full source/build metadata.
|
provide full source/build metadata.
|
||||||
@@ -147,5 +150,5 @@ Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that mu
|
|||||||
## Platform Notes
|
## Platform Notes
|
||||||
|
|
||||||
- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS.
|
- 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.
|
- 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');
|
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', () => {
|
test('Linux package identity does not expose the internal Electron backend project name', () => {
|
||||||
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
|
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
|
||||||
assert.equal(electronBuilderConfig.extraMetadata?.name, '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', () => {
|
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(
|
const releaseFiles = buildAndMakeWorkflow.match(
|
||||||
/files: \|\n([\s\S]*?)\n\s+env:/
|
/files: \|\r?\n([\s\S]*?)\r?\n\s+env:/
|
||||||
)?.[1];
|
)?.[1];
|
||||||
|
|
||||||
assert.ok(releaseFiles, 'release upload files block must exist');
|
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 [
|
for (const [platform, runtimeFile] of [
|
||||||
['windows', 'mpv-2.dll'],
|
['windows', 'mpv-2.dll'],
|
||||||
['windows', 'libmpv-2.dll'],
|
['windows', 'libmpv-2.dll'],
|
||||||
['windows', join('lib', 'mpv.dll')],
|
['windows', 'mpv.dll'],
|
||||||
['windows', join('lib', 'libmpv.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(
|
const nativeDir = join(
|
||||||
resourceDir,
|
resourceDir,
|
||||||
'app.asar.unpacked',
|
'app.asar.unpacked',
|
||||||
@@ -322,6 +361,32 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
|
|||||||
);
|
);
|
||||||
fs.writeFileSync(join(nativeDir, runtimeFile), '');
|
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(
|
assert.deepEqual(
|
||||||
validatePackagedEmbeddedMpv(resourceDir, {
|
validatePackagedEmbeddedMpv(resourceDir, {
|
||||||
platform,
|
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
|
// Host-agnostic assertion: on non-macOS hosts the validator also
|
||||||
// reports that link validation needs a macOS host, so only the
|
// reports that link validation needs a macOS host, so only the
|
||||||
// frame-copy artifact requirement is asserted here.
|
// frame-copy artifact requirement is asserted here.
|
||||||
const remainingErrors = validatePackagedEmbeddedMpv(
|
const remainingErrors = validatePackagedEmbeddedMpv(darwinResourceDir, {
|
||||||
darwinResourceDir,
|
platform: 'darwin',
|
||||||
{ platform: 'darwin', required: true }
|
required: true,
|
||||||
);
|
});
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!remainingErrors.some((error) =>
|
!remainingErrors.some((error) =>
|
||||||
error.includes('frame-copy artifact')
|
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', () => {
|
test('Windows embedded MPV staging preserves import-library DLL basenames', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
embeddedMpvStageRuntimeSource,
|
embeddedMpvStageRuntimeSource,
|
||||||
@@ -472,10 +669,7 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
|
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
|
||||||
const windowsHelperPath = join(
|
const windowsHelperPath = join(tempDir, 'iptvnator_mpv_helper.exe');
|
||||||
tempDir,
|
|
||||||
'iptvnator_mpv_helper.exe'
|
|
||||||
);
|
|
||||||
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
|
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
|
||||||
fs.writeFileSync(helperPath, '#!/bin/sh\n');
|
fs.writeFileSync(helperPath, '#!/bin/sh\n');
|
||||||
fs.chmodSync(helperPath, 0o644);
|
fs.chmodSync(helperPath, 0o644);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ const { spawnSync } = require('child_process');
|
|||||||
|
|
||||||
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
|
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
|
||||||
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
|
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
|
||||||
|
const windowsMpvRuntimeNames = [
|
||||||
|
'mpv-2.dll',
|
||||||
|
'libmpv-2.dll',
|
||||||
|
'mpv.dll',
|
||||||
|
'libmpv.dll',
|
||||||
|
];
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
const result = spawnSync(command, args, {
|
const result = spawnSync(command, args, {
|
||||||
@@ -67,6 +73,177 @@ function listRuntimeFiles(directoryPath) {
|
|||||||
.sort();
|
.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) {
|
function parseOtoolDependencies(binaryPath) {
|
||||||
const output = run('otool', ['-L', binaryPath]);
|
const output = run('otool', ['-L', binaryPath]);
|
||||||
return output
|
return output
|
||||||
@@ -426,16 +603,12 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
|
|||||||
path.join(libDir, 'libmpv.dylib'),
|
path.join(libDir, 'libmpv.dylib'),
|
||||||
];
|
];
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return [
|
return windowsMpvRuntimeNames.flatMap((name) =>
|
||||||
nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null,
|
[
|
||||||
nativeDir ? path.join(nativeDir, 'libmpv-2.dll') : null,
|
nativeDir ? path.join(nativeDir, name) : null,
|
||||||
nativeDir ? path.join(nativeDir, 'mpv.dll') : null,
|
path.join(libDir, name),
|
||||||
nativeDir ? path.join(nativeDir, 'libmpv.dll') : null,
|
].filter(Boolean)
|
||||||
path.join(libDir, 'mpv-2.dll'),
|
);
|
||||||
path.join(libDir, 'libmpv-2.dll'),
|
|
||||||
path.join(libDir, 'mpv.dll'),
|
|
||||||
path.join(libDir, 'libmpv.dll'),
|
|
||||||
].filter(Boolean);
|
|
||||||
case 'linux':
|
case 'linux':
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
@@ -480,7 +653,11 @@ function getEmbeddedMpvAddonArch(env = process.env) {
|
|||||||
* dist output. Those packages must not ship a foreign-architecture
|
* dist output. Those packages must not ship a foreign-architecture
|
||||||
* `embedded_mpv.node` — it can never load and produces a cryptic error.
|
* `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') {
|
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -540,12 +717,16 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin' || platform === 'win32') {
|
||||||
// The frame-copy engine artifacts are built by the same binding.gyp
|
// The frame-copy engine artifacts are built by the same binding.gyp
|
||||||
// run as the addon; a macOS package that ships the addon without
|
// run as the addon; a macOS/Windows package that ships the addon
|
||||||
// them would silently lose the engine (support probe hides it).
|
// 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 = [
|
const missingFrameCopyArtifacts = [
|
||||||
'iptvnator_mpv_helper',
|
platform === 'win32'
|
||||||
|
? 'iptvnator_mpv_helper.exe'
|
||||||
|
: 'iptvnator_mpv_helper',
|
||||||
'embedded_mpv_frame_reader.node',
|
'embedded_mpv_frame_reader.node',
|
||||||
]
|
]
|
||||||
.map((name) => path.join(unpackedNativeDir, name))
|
.map((name) => path.join(unpackedNativeDir, name))
|
||||||
@@ -556,6 +737,77 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
|||||||
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
|
`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)) {
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
|||||||
Reference in new issue
Block a user