mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(embedded-mpv): Linux port of the frame-copy rendering engine (headless EGL) (#1171)
* feat(embedded-mpv): Linux frame-copy helper via headless EGL Port the frame-copy engine's native layer to Linux (PORTING.md items 1-4): - frame_helper_gl.h: platform GlContext abstraction. macOS keeps the CGL path (moved verbatim); Linux acquires an EGL display in order surfaceless-Mesa -> default display -> GBM render node, binds a 3.2 core desktop-GL context surfaceless (1x1 pbuffer fallback), and hands mpv eglGetProcAddress. The helper's own GL calls link against glvnd libOpenGL, so no display server is required. - frame_shm.h: portable frame_shm_now_ns() (CLOCK_MONOTONIC) shared by the helper and the reader addon, replacing the macOS-only clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); producer and consumer stay on the same clock. - embedded_mpv_frame_reader.c: real implementation now also on __linux__ (the code was already POSIX apart from the clock call). - binding.gyp: OS==linux executable branch for iptvnator_mpv_helper linking system libmpv (-lmpv) + EGL/OpenGL/gbm, with rpaths for $ORIGIN/lib and the build-time library dir. The in-process addon still does not link libmpv - the ban only binds in-process, the helper is out of process. - build-embedded-mpv.js: system-dev fallback on Linux (LIBMPV_INCLUDE_DIR or /usr/include) so a distro libmpv-dev install builds without staging a vendored runtime; a pre-set LINUX_NATIVE_LIBRARY_DIR now wins over the vendored lib dir. Verified on Ubuntu 25.04 / i7-1165G7 (Iris Xe): lavfi smoke per PORTING.md (idle->loading->playing snapshots at 4 Hz, aspect-fit generation bump g1 1280x720 -> g2 960x720 for a 4:3 source), reader probe 60 fps at 1080p60 with 0 torn reads, clean quit with no leaked processes or shm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(embedded-mpv): enable the frame-copy engine gates on Linux Flip the TypeScript side of the Linux port (PORTING.md item 5). A shared dependency-free predicate, isFrameCopyPlatformSupported() (linux any-arch, darwin arm64-only), now backs all four gates so they cannot drift: - main.ts: the persisted Settings toggle promotes to the env flag on Linux too (this runs before window creation and controls the sandbox relax). - EmbeddedMpvNativeService.isFrameCopyEngineActive/isFrameCopyAvailable. - EmbeddedMpvFrameCopyAdapter.isSupported. getSupport() ordering: the frame-copy branch moves above the Linux-only native-engine prerequisites - the X11/Xwayland display-server check and the system-mpv-on-PATH probe only bind the --wid native engine, while the frame-copy helper renders offscreen (headless EGL) and links libmpv itself. createSession() also skips resolving the native window handle for frame-copy sessions, which the adapter ignores anyway, so native-Wayland sessions no longer trip the window-handle assertion. Settings copy: the i18n frame-copy description now says macOS (Apple Silicon) and Linux in all 18 languages; stale macOS-only doc comments in the settings/support interfaces updated alongside. Tests: platform-gate matrix for the adapter (darwin arm64/x64, linux x64/arm64, win32) and service specs covering Linux activation under native Wayland, macOS arm64 staying active, macOS x64 staying native, and the skipped window handle for frame-copy sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(packaging): CI + package guards for the Linux frame-copy helper - build-and-make.yaml: install libegl-dev/libgl-dev/libgbm-dev on the Linux runner (the helper's EGL backend needs them now that the helper target builds on Linux), and verify the built helper exists and DOES link libmpv - the inverse of the addon's no-libmpv rule, which still holds and stays validated. - electron-after-pack.cjs: strip iptvnator_mpv_helper from packaged Linux apps. It links the build host's system libmpv, which end-user systems cannot be assumed to have; the support probe treats the missing helper as frame-copy-unavailable (dev-build-only engine until the bundled-runtime staging milestone). - frame_helper_gl.h: log the chosen EGL display tier to stderr (the adapter mirrors helper stderr), so bring-up problems on exotic setups are diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(embedded-mpv): document the Linux frame-copy port - architecture doc: frame-copy section covers Linux (EGL display tiers, build deps, package strip), Linux support matrix notes the frame-copy exception to the X11 + system-mpv requirements, Linux measured baseline. - RESULTS.md: Ubuntu 25.04 / i7-1165G7 (Iris Xe) measurement rows via the production helper + reader probe; viewport-size claim reproduced. - PORTING.md: Linux marked done with pointers to what changed; Windows remains the open port and its perf gate the open decision. - CLAUDE.md + tools/embedded-mpv/README.md: platform scope, Linux dev build requirements, system-headers fallback, helper strip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(embedded-mpv): commit the Linux frame-copy measurement probe linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the production helper, attaches the frame-reader addon to the announced shm generation, and reports new-frame fps, copy wall time, produce->copy age, torn reads and pixel spread. Usage documented in RESULTS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address multi-agent review findings on the Linux port Confirmed findings (each verified by 3 adversarial reviewers): - CI would fail to link the helper: -lOpenGL needs the unversioned glvnd libOpenGL.so, shipped only by libopengl-dev, which neither the runner images nor the previous apt line provide. Added to the workflow and to every documented Linux build-dep list. - The new 'test -x' dist guard could never pass: webpack's dist asset copy drops file modes (helper arrives as 0644). The guard is now 'test -f'; electron-after-pack.cjs restores the execute bit on packaged helpers (also fixes packaged-macOS spawns); the support probe now requires X_OK, so a mode-stripped helper reads as frame-copy-unavailable and falls back to native instead of failing spawn with EACCES. - The Settings frame-copy toggle was unreachable in exactly the Linux states the port targets: the native-Wayland and missing-system-mpv unsupported payloads omitted frameCopyAvailable, and toggle visibility derives solely from it. Both returns now advertise availability. Also from review: - build-embedded-mpv.js keeps the old graceful-skip contract when the new system-dev fallback finds libmpv-dev but the GL/EGL/gbm dev stack is missing (previously such machines skipped; a hard electron-build failure was a regression). - createSession derives the window-handle skip from the dispatched addon instead of re-evaluating the engine gate, so the two cannot disagree. - The render thread logs the GL renderer string (surfaceless Mesa can silently pick llvmpipe on non-Mesa-primary systems; now diagnosable — verified 'Mesa Intel Iris Xe' on this machine). - Specs pin the new semantics: frameCopyAvailable advertised while native is unsupported (Wayland / no mpv), frame-copy supported without a system mpv, and the handle-skip test disposes its session through the owning adapter. - Docs: PORTING.md file map reflects the frame_helper_gl.h seam for the Windows porter; helper-strip removal correctly gated on milestone 4 (bundled libmpv), not milestone 3; RESULTS.md preamble notes the RAW->MONOTONIC clock change; stale '(macOS)' scope comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address Greptile/Codex review comments - Sandbox gate requires a usable helper (Greptile P1, security): the main.ts env promotion now also probes for an executable iptvnator_mpv_helper before relaxing the window sandbox — a stale opt-in on packaged Linux (helper deliberately stripped) or after a cleaned native build no longer costs a sandboxless launch for an engine that cannot activate. Helper discovery (addon candidate paths + X_OK probe) moved into embedded-mpv-frame-copy-platform.util.ts, shared by main.ts and the service; the service keeps thin instance wrappers so tests can stub per scenario. New util spec pins the platform matrix, candidate resolution, and the execute-bit semantics. - Stale frame-copy artifacts on skipped builds (Codex P2): cleanOutput() now also removes iptvnator_mpv_helper and embedded_mpv_frame_reader.node, so a failed/skipped rebuild cannot leave a previous helper advertising frame-copy support against a runtime the build just declared unavailable. - Multiarch default lib dir (Greptile P1, partially refuted): -l resolution never depended on our -L (the compiler's built-in search paths include the Debian/Ubuntu multiarch dir — proven by the green CI run linking with a nonexistent -L dir), but the system-dev fallback now defaults to /usr/lib/<multiarch-triple> when present so the -L flag and the helper's baked rpath point somewhere real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): harden Linux frame-copy port --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26271fc076
commit
7d75d989e8
45 files changed
+1387
-177
No files matched your search
@@ -68,7 +68,7 @@ jobs:
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config
|
||||
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev
|
||||
|
||||
# Configure Flatpak
|
||||
# 1. Add the Flathub repository (source of runtimes)
|
||||
@@ -336,6 +336,18 @@ jobs:
|
||||
ldd dist/apps/electron-backend/native/embedded_mpv.node
|
||||
exit 1
|
||||
fi
|
||||
# The frame-copy helper is the inverse: a separate process
|
||||
# that MUST link libmpv (dev-mode engine; stripped from
|
||||
# packages until the bundled-runtime staging lands).
|
||||
# test -f, not -x: the webpack dist asset copy drops file
|
||||
# modes; consumers restore the bit (after-pack) or require
|
||||
# it via the X_OK support probe.
|
||||
test -f dist/apps/electron-backend/native/iptvnator_mpv_helper
|
||||
if ! ldd dist/apps/electron-backend/native/iptvnator_mpv_helper | grep -q 'libmpv'; then
|
||||
echo "::error::Linux frame-copy helper must link libmpv"
|
||||
ldd dist/apps/electron-backend/native/iptvnator_mpv_helper
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -617,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Built-in HTML5 player with HLS.js or Video.js
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon only; 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 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 (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). 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. 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; 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").
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
|
||||
@@ -182,6 +182,23 @@ function findWindowsLibMpv(runtimeRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Debian/Ubuntu install linker targets under the multiarch triple dir. The
|
||||
* compiler's built-in search paths cover it for -l resolution either way;
|
||||
* this keeps the -L flag and the helper's baked rpath pointing somewhere
|
||||
* real. */
|
||||
function defaultLinuxSystemLibDir() {
|
||||
const multiarchTriples = {
|
||||
arm: 'arm-linux-gnueabihf',
|
||||
arm64: 'aarch64-linux-gnu',
|
||||
x64: 'x86_64-linux-gnu',
|
||||
};
|
||||
const triple = multiarchTriples[targetArch];
|
||||
if (triple && fs.existsSync(`/usr/lib/${triple}`)) {
|
||||
return `/usr/lib/${triple}`;
|
||||
}
|
||||
return '/usr/lib';
|
||||
}
|
||||
|
||||
function findLinuxLibMpv(libDir) {
|
||||
for (const candidate of ['libmpv.so.2', 'libmpv.so.1', 'libmpv.so']) {
|
||||
const candidatePath = path.join(libDir, candidate);
|
||||
@@ -223,6 +240,30 @@ function resolveRuntime() {
|
||||
};
|
||||
}
|
||||
|
||||
if (targetPlatform === 'linux') {
|
||||
// Dev-first Linux flow (frame-copy helper links system libmpv): a
|
||||
// distro libmpv-dev install is a full runtime — no staging needed.
|
||||
// LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system
|
||||
// paths for machines with a local (non-root) libmpv prefix.
|
||||
const systemIncludeDir =
|
||||
process.env.LIBMPV_INCLUDE_DIR || '/usr/include';
|
||||
if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) {
|
||||
return {
|
||||
origin: 'system-dev',
|
||||
includeDir: systemIncludeDir,
|
||||
libDir:
|
||||
process.env.LINUX_NATIVE_LIBRARY_DIR ||
|
||||
defaultLinuxSystemLibDir(),
|
||||
binDir: undefined,
|
||||
manifest: {
|
||||
warning:
|
||||
'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.',
|
||||
},
|
||||
windowsImportLib: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPlatform === 'darwin' && homebrewFallbackEnabled) {
|
||||
const homebrewLibMpv = findLibMpv(homebrewLibDir);
|
||||
const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h');
|
||||
@@ -454,7 +495,10 @@ function main() {
|
||||
npm_config_update_binary: 'false',
|
||||
LIBMPV_INCLUDE_DIR: runtime.includeDir,
|
||||
...(targetPlatform === 'linux'
|
||||
? { LINUX_NATIVE_LIBRARY_DIR: runtime.libDir }
|
||||
? {
|
||||
LINUX_NATIVE_LIBRARY_DIR:
|
||||
process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir,
|
||||
}
|
||||
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
|
||||
...(runtime.windowsImportLib
|
||||
? {
|
||||
@@ -470,8 +514,25 @@ function main() {
|
||||
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
|
||||
);
|
||||
cleanNativeBuildIntermediates();
|
||||
runNodeGyp('configure', env);
|
||||
runNodeGyp('build', env);
|
||||
try {
|
||||
runNodeGyp('configure', env);
|
||||
runNodeGyp('build', env);
|
||||
} catch (error) {
|
||||
if (!embeddedMpvRequired && runtime.origin === 'system-dev') {
|
||||
// The system-dev fallback triggers on any machine with
|
||||
// libmpv-dev installed; keep the old graceful-skip contract
|
||||
// when the rest of the toolchain (EGL/GL/gbm dev packages) is
|
||||
// missing instead of failing the whole electron build.
|
||||
log(
|
||||
`Embedded MPV native build failed with the system-dev toolchain; continuing without embedded MPV. ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
cleanOutput();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(outputFile)) {
|
||||
throw new Error(`Build finished without producing ${outputFile}.`);
|
||||
|
||||
@@ -117,6 +117,42 @@
|
||||
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || '/opt/homebrew/lib'; process.stdout.write(path.join(dir, 'libmpv.2.dylib'))\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"OS==\"linux\"",
|
||||
{
|
||||
"type": "executable",
|
||||
"sources": [
|
||||
"helper/mpv_frame_helper.cpp"
|
||||
],
|
||||
"include_dirs": [
|
||||
"<!(node -p \"process.env.LIBMPV_INCLUDE_DIR || '/usr/include'\")",
|
||||
"helper"
|
||||
],
|
||||
"cflags_cc!": [
|
||||
"-fno-exceptions"
|
||||
],
|
||||
"cflags_cc": [
|
||||
"-std=c++17",
|
||||
"-fexceptions"
|
||||
],
|
||||
"cflags": [
|
||||
"-pthread"
|
||||
],
|
||||
"ldflags": [
|
||||
"-pthread",
|
||||
"-Wl,-rpath,'$$ORIGIN/lib'",
|
||||
"-Wl,-rpath,<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")"
|
||||
],
|
||||
"libraries": [
|
||||
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
|
||||
"-lmpv",
|
||||
"-lEGL",
|
||||
"-lOpenGL",
|
||||
"-lgbm",
|
||||
"-ldl"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/*
|
||||
* Headless GL context for the frame-copy helper, one implementation per
|
||||
* platform behind the same tiny GlContext surface.
|
||||
*
|
||||
* macOS: accelerated CGL 3.2 core context; GL entry points for mpv come
|
||||
* from the OpenGL framework via dlsym.
|
||||
*
|
||||
* Linux: EGL display candidates are fully probed in order — Mesa's
|
||||
* surfaceless platform (no display server needed), the session's default
|
||||
* display, then a GBM render node. The first hardware renderer wins; an
|
||||
* earlier software renderer is recreated only if no hardware tier works.
|
||||
* Each tier uses a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
|
||||
* fallback for drivers without EGL_KHR_surfaceless_context). The helper's
|
||||
* own GL calls resolve at link time through libOpenGL (glvnd); mpv resolves
|
||||
* core symbols from that linked library and falls back to eglGetProcAddress
|
||||
* for extensions.
|
||||
*
|
||||
* Threading contract: create() runs on the main thread and must leave the
|
||||
* context unbound; the render thread calls makeCurrent() once and owns the
|
||||
* context until destroy().
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
#define GL_SILENCE_DEPRECATION
|
||||
#include <OpenGL/OpenGL.h>
|
||||
#include <OpenGL/gl3.h>
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
#elif defined(__linux__)
|
||||
|
||||
/* Keep eglplatform.h away from Xlib types: the helper never uses native
|
||||
* window/display handles, so X11 headers are an unnecessary build dep. */
|
||||
#define EGL_NO_X11 1
|
||||
#define MESA_EGL_NO_X11_HEADERS 1
|
||||
#define GL_GLEXT_PROTOTYPES 1
|
||||
#include <EGL/egl.h>
|
||||
#include <EGL/eglext.h>
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glext.h>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <gbm.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <dlfcn.h>
|
||||
|
||||
#ifndef EGL_PLATFORM_SURFACELESS_MESA
|
||||
#define EGL_PLATFORM_SURFACELESS_MESA 0x31DD
|
||||
#endif
|
||||
#ifndef EGL_PLATFORM_GBM_KHR
|
||||
#define EGL_PLATFORM_GBM_KHR 0x31D7
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
namespace frame_helper {
|
||||
|
||||
/* Matches mpv_opengl_init_params.get_proc_address. */
|
||||
using GlGetProcAddressFn = void* (*)(void* ctx, const char* name);
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
inline void* glDlsymGetProcAddress(void* ctx, const char* name) {
|
||||
return dlsym(ctx, name);
|
||||
}
|
||||
|
||||
class GlContext {
|
||||
public:
|
||||
bool create(std::string& errorOut) {
|
||||
glDylib_ = dlopen(
|
||||
"/System/Library/Frameworks/OpenGL.framework/Versions/Current/"
|
||||
"OpenGL",
|
||||
RTLD_LAZY | RTLD_LOCAL);
|
||||
if (!glDylib_) {
|
||||
errorOut = "failed to open the OpenGL framework";
|
||||
return false;
|
||||
}
|
||||
|
||||
CGLPixelFormatAttribute attrs[] = {
|
||||
kCGLPFAOpenGLProfile,
|
||||
(CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core,
|
||||
kCGLPFAAccelerated,
|
||||
kCGLPFAColorSize, (CGLPixelFormatAttribute)24,
|
||||
kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8,
|
||||
(CGLPixelFormatAttribute)0,
|
||||
};
|
||||
CGLPixelFormatObj pixelFormat = nullptr;
|
||||
GLint matched = 0;
|
||||
if (CGLChoosePixelFormat(attrs, &pixelFormat, &matched) !=
|
||||
kCGLNoError ||
|
||||
!pixelFormat) {
|
||||
errorOut = "no accelerated CGL pixel format";
|
||||
return false;
|
||||
}
|
||||
const CGLError contextError =
|
||||
CGLCreateContext(pixelFormat, nullptr, &cgl_);
|
||||
CGLDestroyPixelFormat(pixelFormat);
|
||||
if (contextError != kCGLNoError || !cgl_) {
|
||||
errorOut = "failed to create a headless CGL context";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool makeCurrent(std::string& errorOut) {
|
||||
if (CGLSetCurrentContext(cgl_) != kCGLNoError) {
|
||||
errorOut = "failed to bind the CGL render context";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
CGLSetCurrentContext(nullptr);
|
||||
if (cgl_) CGLDestroyContext(cgl_);
|
||||
cgl_ = nullptr;
|
||||
/* glDylib_ stays mapped: mpv may still resolve symbols during its
|
||||
* own teardown, and the process exits right after anyway. */
|
||||
}
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; }
|
||||
void* procLoaderCtx() const { return glDylib_; }
|
||||
|
||||
private:
|
||||
CGLContextObj cgl_ = nullptr;
|
||||
void* glDylib_ = nullptr;
|
||||
};
|
||||
|
||||
#elif defined(__linux__)
|
||||
|
||||
inline void* eglWrapGetProcAddress(void* /*ctx*/, const char* name) {
|
||||
if (void* linkedSymbol = dlsym(RTLD_DEFAULT, name)) return linkedSymbol;
|
||||
return reinterpret_cast<void*>(eglGetProcAddress(name));
|
||||
}
|
||||
|
||||
class GlContext {
|
||||
public:
|
||||
bool create(std::string& errorOut) {
|
||||
const DisplayTier tiers[] = {
|
||||
DisplayTier::SurfacelessMesa,
|
||||
DisplayTier::Default,
|
||||
DisplayTier::Gbm,
|
||||
};
|
||||
|
||||
bool hasSoftwareFallback = false;
|
||||
DisplayTier softwareFallback = DisplayTier::SurfacelessMesa;
|
||||
std::string failures;
|
||||
|
||||
for (DisplayTier tier : tiers) {
|
||||
Candidate candidate;
|
||||
std::string candidateError;
|
||||
if (!tryCandidate(tier, candidate, candidateError)) {
|
||||
if (candidateOwnsResources(candidate)) {
|
||||
errorOut = candidateError +
|
||||
"; failed to safely tear down rejected EGL "
|
||||
"candidate";
|
||||
return false;
|
||||
}
|
||||
appendFailure(failures, candidateError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidate.softwareRenderer) {
|
||||
adoptCandidate(candidate);
|
||||
std::fprintf(stderr, "egl display: %s\n", tierName(tier));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::fprintf(stderr,
|
||||
"egl candidate %s uses software renderer %s; "
|
||||
"trying the next tier\n",
|
||||
tierName(tier), candidate.renderer.c_str());
|
||||
if (!hasSoftwareFallback) {
|
||||
softwareFallback = tier;
|
||||
hasSoftwareFallback = true;
|
||||
}
|
||||
destroyCandidate(candidate);
|
||||
if (candidateOwnsResources(candidate)) {
|
||||
errorOut = std::string(tierName(tier)) +
|
||||
": failed to safely release software EGL "
|
||||
"candidate";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recreate rather than retain the software context while probing the
|
||||
* remaining tiers: EGL implementations may alias platform displays,
|
||||
* and terminating one alias can invalidate the retained context. */
|
||||
if (hasSoftwareFallback) {
|
||||
Candidate candidate;
|
||||
std::string candidateError;
|
||||
if (tryCandidate(softwareFallback, candidate, candidateError)) {
|
||||
adoptCandidate(candidate);
|
||||
std::fprintf(stderr,
|
||||
"egl display: %s (software fallback: %s)\n",
|
||||
tierName(softwareFallback), renderer_.c_str());
|
||||
return true;
|
||||
}
|
||||
if (candidateOwnsResources(candidate)) {
|
||||
errorOut = candidateError +
|
||||
"; failed to safely tear down software fallback";
|
||||
return false;
|
||||
}
|
||||
appendFailure(failures, candidateError);
|
||||
}
|
||||
|
||||
errorOut = "no usable EGL display (surfaceless/default/GBM)";
|
||||
if (!failures.empty()) errorOut += ": " + failures;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool makeCurrent(std::string& errorOut) {
|
||||
/* eglBindAPI is thread-local. create() runs on the main thread, so the
|
||||
* render thread must select desktop GL for both bind and teardown. */
|
||||
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
|
||||
errorOut = "failed to bind the desktop OpenGL EGL API";
|
||||
return false;
|
||||
}
|
||||
if (eglMakeCurrent(display_, surface_, surface_, context_) != EGL_TRUE) {
|
||||
errorOut = "failed to bind the EGL render context";
|
||||
eglReleaseThread();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
if (display_ != EGL_NO_DISPLAY) {
|
||||
bool released = true;
|
||||
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
|
||||
std::fprintf(stderr,
|
||||
"egl teardown: failed to bind desktop OpenGL\n");
|
||||
released = false;
|
||||
} else if (eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE,
|
||||
EGL_NO_CONTEXT) != EGL_TRUE) {
|
||||
std::fprintf(stderr,
|
||||
"egl teardown: failed to release current "
|
||||
"context\n");
|
||||
released = false;
|
||||
}
|
||||
if (eglReleaseThread() != EGL_TRUE) {
|
||||
std::fprintf(stderr,
|
||||
"egl teardown: eglReleaseThread failed\n");
|
||||
released = false;
|
||||
}
|
||||
/* Never tear down the display/GBM device under a context that EGL
|
||||
* may still consider current. The process is already exiting, so
|
||||
* leaking on this exceptional path is safer than use-after-free. */
|
||||
if (!released) return;
|
||||
|
||||
if (surface_ != EGL_NO_SURFACE)
|
||||
eglDestroySurface(display_, surface_);
|
||||
if (context_ != EGL_NO_CONTEXT)
|
||||
eglDestroyContext(display_, context_);
|
||||
eglTerminate(display_);
|
||||
}
|
||||
surface_ = EGL_NO_SURFACE;
|
||||
context_ = EGL_NO_CONTEXT;
|
||||
display_ = EGL_NO_DISPLAY;
|
||||
if (gbmDevice_) {
|
||||
gbm_device_destroy(gbmDevice_);
|
||||
gbmDevice_ = nullptr;
|
||||
}
|
||||
if (gbmFd_ >= 0) {
|
||||
close(gbmFd_);
|
||||
gbmFd_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; }
|
||||
void* procLoaderCtx() const { return nullptr; }
|
||||
|
||||
private:
|
||||
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
|
||||
|
||||
struct Candidate {
|
||||
EGLDisplay display = EGL_NO_DISPLAY;
|
||||
EGLContext context = EGL_NO_CONTEXT;
|
||||
EGLSurface surface = EGL_NO_SURFACE;
|
||||
struct gbm_device* gbmDevice = nullptr;
|
||||
int gbmFd = -1;
|
||||
bool initialized = false;
|
||||
bool current = false;
|
||||
bool threadReleased = false;
|
||||
bool softwareRenderer = false;
|
||||
std::string renderer;
|
||||
};
|
||||
|
||||
static const char* tierName(DisplayTier tier) {
|
||||
switch (tier) {
|
||||
case DisplayTier::SurfacelessMesa:
|
||||
return "surfaceless-mesa";
|
||||
case DisplayTier::Default:
|
||||
return "default";
|
||||
case DisplayTier::Gbm:
|
||||
return "gbm render node";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
static void appendFailure(std::string& failures,
|
||||
const std::string& failure) {
|
||||
if (failure.empty()) return;
|
||||
if (!failures.empty()) failures += "; ";
|
||||
failures += failure;
|
||||
}
|
||||
|
||||
static bool candidateOwnsResources(const Candidate& candidate) {
|
||||
return candidate.display != EGL_NO_DISPLAY ||
|
||||
candidate.context != EGL_NO_CONTEXT ||
|
||||
candidate.surface != EGL_NO_SURFACE || candidate.gbmDevice ||
|
||||
candidate.gbmFd >= 0 || candidate.current;
|
||||
}
|
||||
|
||||
static bool isSoftwareRenderer(const std::string& renderer) {
|
||||
std::string normalized = renderer;
|
||||
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
|
||||
[](unsigned char value) {
|
||||
return static_cast<char>(std::tolower(value));
|
||||
});
|
||||
for (const char* marker : {"llvmpipe", "softpipe", "swrast",
|
||||
"software rasterizer", "lavapipe"}) {
|
||||
if (normalized.find(marker) != std::string::npos) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool openGbmDevice(Candidate& candidate) {
|
||||
for (int node = 128; node <= 131; node++) {
|
||||
char devicePath[32];
|
||||
std::snprintf(devicePath, sizeof(devicePath),
|
||||
"/dev/dri/renderD%d", node);
|
||||
const int fd = open(devicePath, O_RDWR | O_CLOEXEC);
|
||||
if (fd < 0) continue;
|
||||
struct gbm_device* device = gbm_create_device(fd);
|
||||
if (!device) {
|
||||
close(fd);
|
||||
continue;
|
||||
}
|
||||
candidate.gbmFd = fd;
|
||||
candidate.gbmDevice = device;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool chooseConfig(EGLDisplay display, EGLint surfaceType,
|
||||
EGLConfig* out) {
|
||||
const EGLint attrs[] = {
|
||||
EGL_SURFACE_TYPE, surfaceType,
|
||||
EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
|
||||
EGL_RED_SIZE, 8,
|
||||
EGL_GREEN_SIZE, 8,
|
||||
EGL_BLUE_SIZE, 8,
|
||||
EGL_ALPHA_SIZE, 8,
|
||||
EGL_NONE,
|
||||
};
|
||||
EGLint matched = 0;
|
||||
return eglChooseConfig(display, attrs, out, 1, &matched) == EGL_TRUE &&
|
||||
matched > 0;
|
||||
}
|
||||
|
||||
bool tryCandidate(DisplayTier tier, Candidate& candidate,
|
||||
std::string& errorOut) {
|
||||
const auto fail = [&](const std::string& detail) {
|
||||
errorOut = std::string(tierName(tier)) + ": " + detail;
|
||||
destroyCandidate(candidate);
|
||||
return false;
|
||||
};
|
||||
|
||||
switch (tier) {
|
||||
case DisplayTier::SurfacelessMesa:
|
||||
candidate.display = eglGetPlatformDisplay(
|
||||
EGL_PLATFORM_SURFACELESS_MESA, nullptr, nullptr);
|
||||
break;
|
||||
case DisplayTier::Default:
|
||||
candidate.display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
break;
|
||||
case DisplayTier::Gbm:
|
||||
if (!openGbmDevice(candidate)) {
|
||||
return fail("no accessible DRM render node");
|
||||
}
|
||||
candidate.display = eglGetPlatformDisplay(
|
||||
EGL_PLATFORM_GBM_KHR, candidate.gbmDevice, nullptr);
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate.display == EGL_NO_DISPLAY) {
|
||||
return fail("eglGetDisplay returned EGL_NO_DISPLAY");
|
||||
}
|
||||
EGLint major = 0;
|
||||
EGLint minor = 0;
|
||||
if (eglInitialize(candidate.display, &major, &minor) != EGL_TRUE) {
|
||||
return fail("eglInitialize failed");
|
||||
}
|
||||
candidate.initialized = true;
|
||||
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
|
||||
return fail("desktop OpenGL API unavailable");
|
||||
}
|
||||
|
||||
EGLConfig config = nullptr;
|
||||
if (!chooseConfig(candidate.display, EGL_PBUFFER_BIT, &config) &&
|
||||
!chooseConfig(candidate.display, 0, &config)) {
|
||||
return fail("no desktop-OpenGL EGLConfig");
|
||||
}
|
||||
const EGLint contextAttrs[] = {
|
||||
EGL_CONTEXT_MAJOR_VERSION,
|
||||
3,
|
||||
EGL_CONTEXT_MINOR_VERSION,
|
||||
2,
|
||||
EGL_CONTEXT_OPENGL_PROFILE_MASK,
|
||||
EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
|
||||
EGL_NONE,
|
||||
};
|
||||
candidate.context = eglCreateContext(candidate.display, config,
|
||||
EGL_NO_CONTEXT, contextAttrs);
|
||||
if (candidate.context == EGL_NO_CONTEXT) {
|
||||
return fail("failed to create a 3.2 core context");
|
||||
}
|
||||
|
||||
if (eglMakeCurrent(candidate.display, EGL_NO_SURFACE, EGL_NO_SURFACE,
|
||||
candidate.context) != EGL_TRUE) {
|
||||
const EGLint pbufferAttrs[] = {
|
||||
EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE,
|
||||
};
|
||||
candidate.surface = eglCreatePbufferSurface(
|
||||
candidate.display, config, pbufferAttrs);
|
||||
if (candidate.surface == EGL_NO_SURFACE ||
|
||||
eglMakeCurrent(candidate.display, candidate.surface,
|
||||
candidate.surface, candidate.context) !=
|
||||
EGL_TRUE) {
|
||||
return fail(
|
||||
"eglMakeCurrent failed (surfaceless and pbuffer)");
|
||||
}
|
||||
}
|
||||
candidate.current = true;
|
||||
|
||||
const GLubyte* renderer = glGetString(GL_RENDERER);
|
||||
if (!renderer) return fail("GL_RENDERER unavailable");
|
||||
candidate.renderer = reinterpret_cast<const char*>(renderer);
|
||||
candidate.softwareRenderer = isSoftwareRenderer(candidate.renderer);
|
||||
|
||||
if (eglMakeCurrent(candidate.display, EGL_NO_SURFACE, EGL_NO_SURFACE,
|
||||
EGL_NO_CONTEXT) != EGL_TRUE) {
|
||||
return fail("failed to unbind the probe context");
|
||||
}
|
||||
candidate.current = false;
|
||||
if (eglReleaseThread() != EGL_TRUE) {
|
||||
return fail("eglReleaseThread failed after probing");
|
||||
}
|
||||
candidate.threadReleased = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool releaseCandidateThread(Candidate& candidate) {
|
||||
if (candidate.threadReleased) return true;
|
||||
if (candidate.current) {
|
||||
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE ||
|
||||
eglMakeCurrent(candidate.display, EGL_NO_SURFACE,
|
||||
EGL_NO_SURFACE, EGL_NO_CONTEXT) != EGL_TRUE) {
|
||||
return false;
|
||||
}
|
||||
candidate.current = false;
|
||||
}
|
||||
if (eglReleaseThread() != EGL_TRUE) return false;
|
||||
candidate.threadReleased = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void destroyCandidate(Candidate& candidate) {
|
||||
if (!releaseCandidateThread(candidate)) {
|
||||
std::fprintf(stderr,
|
||||
"egl candidate teardown skipped: context release "
|
||||
"failed\n");
|
||||
return;
|
||||
}
|
||||
if (candidate.display != EGL_NO_DISPLAY) {
|
||||
if (candidate.surface != EGL_NO_SURFACE)
|
||||
eglDestroySurface(candidate.display, candidate.surface);
|
||||
if (candidate.context != EGL_NO_CONTEXT)
|
||||
eglDestroyContext(candidate.display, candidate.context);
|
||||
if (candidate.initialized) eglTerminate(candidate.display);
|
||||
}
|
||||
if (candidate.gbmDevice) gbm_device_destroy(candidate.gbmDevice);
|
||||
if (candidate.gbmFd >= 0) close(candidate.gbmFd);
|
||||
candidate = Candidate{};
|
||||
}
|
||||
|
||||
void adoptCandidate(Candidate& candidate) {
|
||||
display_ = candidate.display;
|
||||
context_ = candidate.context;
|
||||
surface_ = candidate.surface;
|
||||
gbmDevice_ = candidate.gbmDevice;
|
||||
gbmFd_ = candidate.gbmFd;
|
||||
renderer_ = candidate.renderer;
|
||||
candidate = Candidate{};
|
||||
}
|
||||
|
||||
EGLDisplay display_ = EGL_NO_DISPLAY;
|
||||
EGLContext context_ = EGL_NO_CONTEXT;
|
||||
EGLSurface surface_ = EGL_NO_SURFACE;
|
||||
struct gbm_device* gbmDevice_ = nullptr;
|
||||
int gbmFd_ = -1;
|
||||
std::string renderer_;
|
||||
};
|
||||
|
||||
#else
|
||||
#error "frame_helper_gl.h has no GL context implementation for this platform"
|
||||
#endif
|
||||
|
||||
} // namespace frame_helper
|
||||
@@ -1,23 +1,19 @@
|
||||
/*
|
||||
* Offscreen render + readback pipeline for the frame-copy helper (macOS).
|
||||
* Offscreen render + readback pipeline for the frame-copy helper.
|
||||
*
|
||||
* Headless CGL context, mpv render API into an FBO sized to the viewport,
|
||||
* async PBO readback ring, BGRA frames published into the FrameShm ring.
|
||||
* Validated in spikes/mpv-frame-copy (4K60 sustained on M1 Pro).
|
||||
* Headless GL context (per-platform, see frame_helper_gl.h), mpv render API
|
||||
* into an FBO sized to the viewport, async PBO readback ring, BGRA frames
|
||||
* published into the FrameShm ring. Validated in spikes/mpv-frame-copy
|
||||
* (4K60 sustained on M1 Pro).
|
||||
*
|
||||
* Threading: everything here runs on the render thread except
|
||||
* requestResize()/stop(), which only touch atomics/mutex-guarded state.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#define GL_SILENCE_DEPRECATION
|
||||
#include <OpenGL/OpenGL.h>
|
||||
#include <OpenGL/gl3.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
@@ -30,12 +26,13 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "frame_helper_gl.h"
|
||||
#include "frame_helper_io.h"
|
||||
#include "frame_shm.h"
|
||||
|
||||
namespace frame_helper {
|
||||
|
||||
inline uint64_t nowNs() { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); }
|
||||
inline uint64_t nowNs() { return frame_shm_now_ns(); }
|
||||
|
||||
struct ShmRing {
|
||||
FrameShmHeader* header = nullptr;
|
||||
@@ -129,9 +126,8 @@ private:
|
||||
void renderFrame();
|
||||
|
||||
mpv_handle* mpv_ = nullptr;
|
||||
CGLContextObj cgl_ = nullptr;
|
||||
GlContext gl_;
|
||||
mpv_render_context* renderContext_ = nullptr;
|
||||
void* glDylib_ = nullptr;
|
||||
|
||||
std::string shmBaseName_;
|
||||
ShmRing ring_;
|
||||
@@ -155,10 +151,6 @@ private:
|
||||
std::atomic<int> initState_{0};
|
||||
};
|
||||
|
||||
inline void* frameHelperGetProcAddress(void* ctx, const char* name) {
|
||||
return dlsym(ctx, name);
|
||||
}
|
||||
|
||||
inline bool RenderPipeline::start(mpv_handle* mpv,
|
||||
const std::string& shmBaseName, int width,
|
||||
int height, std::string& errorOut) {
|
||||
@@ -166,48 +158,26 @@ inline bool RenderPipeline::start(mpv_handle* mpv,
|
||||
shmBaseName_ = shmBaseName;
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
|
||||
glDylib_ = dlopen(
|
||||
"/System/Library/Frameworks/OpenGL.framework/Versions/Current/OpenGL",
|
||||
RTLD_LAZY | RTLD_LOCAL);
|
||||
if (!glDylib_) {
|
||||
errorOut = "failed to open the OpenGL framework";
|
||||
return false;
|
||||
}
|
||||
|
||||
CGLPixelFormatAttribute attrs[] = {
|
||||
kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core,
|
||||
kCGLPFAAccelerated,
|
||||
kCGLPFAColorSize, (CGLPixelFormatAttribute)24,
|
||||
kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8,
|
||||
(CGLPixelFormatAttribute)0,
|
||||
};
|
||||
CGLPixelFormatObj pixelFormat = nullptr;
|
||||
GLint matched = 0;
|
||||
if (CGLChoosePixelFormat(attrs, &pixelFormat, &matched) != kCGLNoError ||
|
||||
!pixelFormat) {
|
||||
errorOut = "no accelerated CGL pixel format";
|
||||
return false;
|
||||
}
|
||||
const CGLError contextError =
|
||||
CGLCreateContext(pixelFormat, nullptr, &cgl_);
|
||||
CGLDestroyPixelFormat(pixelFormat);
|
||||
if (contextError != kCGLNoError || !cgl_) {
|
||||
errorOut = "failed to create a headless CGL context";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return gl_.create(errorOut);
|
||||
}
|
||||
|
||||
inline bool RenderPipeline::setupGl(std::string& errorOut) {
|
||||
CGLSetCurrentContext(cgl_);
|
||||
if (!gl_.makeCurrent(errorOut)) return false;
|
||||
|
||||
/* Keep the accepted renderer diagnosable. Linux already rejected earlier
|
||||
* software tiers when a later hardware-backed EGL candidate was usable. */
|
||||
const GLubyte* renderer = glGetString(GL_RENDERER);
|
||||
if (renderer) {
|
||||
std::fprintf(stderr, "gl renderer: %s\n",
|
||||
reinterpret_cast<const char*>(renderer));
|
||||
}
|
||||
|
||||
if (!rebuildTargets(width_, height_)) {
|
||||
errorOut = "framebuffer setup failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
mpv_opengl_init_params glInit = {frameHelperGetProcAddress, glDylib_};
|
||||
mpv_opengl_init_params glInit = {gl_.procLoader(), gl_.procLoaderCtx()};
|
||||
mpv_render_param params[] = {
|
||||
{MPV_RENDER_PARAM_API_TYPE,
|
||||
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||
@@ -345,6 +315,8 @@ inline void RenderPipeline::runLoop() {
|
||||
.str("error", "render init failed: " + glError)
|
||||
.finish());
|
||||
initState_.store(-1);
|
||||
gl_.destroy();
|
||||
ring_.destroy();
|
||||
return;
|
||||
}
|
||||
initState_.store(1);
|
||||
@@ -399,8 +371,7 @@ inline void RenderPipeline::runLoop() {
|
||||
if (texture_) glDeleteTextures(1, &texture_);
|
||||
if (fbo_) glDeleteFramebuffers(1, &fbo_);
|
||||
if (pbos_[0]) glDeleteBuffers(FRAME_SHM_RING_SLOTS, pbos_);
|
||||
CGLSetCurrentContext(nullptr);
|
||||
if (cgl_) CGLDestroyContext(cgl_);
|
||||
gl_.destroy();
|
||||
ring_.destroy();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
* resize creates a fresh shm segment named `<base>-g<generation>` and the
|
||||
* reader re-attaches when the helper announces the new generation.
|
||||
*
|
||||
* Must compile as C11 (reader addon) and C++17 (helper).
|
||||
* Must compile as C11 (reader addon) and C++17 (helper). Both consumers
|
||||
* build with node-gyp's GNU dialects; frame_shm_now_ns() relies on POSIX
|
||||
* clock_gettime, which strict -std=c11 (__STRICT_ANSI__) would hide.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
@@ -20,6 +23,16 @@ typedef std::atomic<uint64_t> frame_shm_atomic_u64;
|
||||
typedef _Atomic uint64_t frame_shm_atomic_u64;
|
||||
#endif
|
||||
|
||||
/* Monotonic clock for produce_time_ns/heartbeat_ns. Producer (helper) and
|
||||
* consumer (reader addon) MUST use this same clock so age math stays valid.
|
||||
* CLOCK_MONOTONIC is portable across macOS (10.12+) and Linux; the Windows
|
||||
* port will need a QueryPerformanceCounter shim here. */
|
||||
static inline uint64_t frame_shm_now_ns(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
|
||||
}
|
||||
|
||||
#define FRAME_SHM_MAGIC 0x564d5046u /* 'FPMV' */
|
||||
#define FRAME_SHM_VERSION 1u
|
||||
#define FRAME_SHM_RING_SLOTS 3u
|
||||
@@ -27,7 +40,7 @@ typedef _Atomic uint64_t frame_shm_atomic_u64;
|
||||
|
||||
typedef struct {
|
||||
frame_shm_atomic_u64 seq; /* 0 while the slot is being (re)written */
|
||||
uint64_t produce_time_ns; /* CLOCK_MONOTONIC_RAW after copy completes */
|
||||
uint64_t produce_time_ns; /* frame_shm_now_ns() after copy completes */
|
||||
} FrameShmSlot;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* iptvnator-mpv-helper — frame-copy embedded MPV helper process (macOS).
|
||||
* iptvnator-mpv-helper — frame-copy embedded MPV helper process
|
||||
* (macOS + Linux; platform GL context in frame_helper_gl.h).
|
||||
*
|
||||
* One process = one playback session. Owns libmpv end to end: decodes,
|
||||
* renders offscreen at viewport size, publishes BGRA frames into a shared
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
* memory cage forbids external ArrayBuffers over foreign memory.
|
||||
*
|
||||
* Plain C N-API (no node-addon-api) so it stays ABI-stable and trivial.
|
||||
* macOS-only for now — other platforms export an empty object; the
|
||||
* TypeScript side gates on platform before requiring it.
|
||||
* macOS and Linux (POSIX shm) — other platforms export an empty object;
|
||||
* the TypeScript side gates on platform before requiring it.
|
||||
*/
|
||||
#define NAPI_VERSION 8
|
||||
#include <node_api.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#if defined(__APPLE__) || defined(__linux__)
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdatomic.h>
|
||||
@@ -30,9 +30,8 @@ static FrameShmHeader* g_header = NULL;
|
||||
static uint8_t* g_base = NULL;
|
||||
static size_t g_size = 0;
|
||||
|
||||
static uint64_t now_ns(void) {
|
||||
return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
|
||||
}
|
||||
/* Same clock as the helper's produce_time_ns/heartbeat_ns writers. */
|
||||
static uint64_t now_ns(void) { return frame_shm_now_ns(); }
|
||||
|
||||
static void unmap_current(void) {
|
||||
if (g_base) {
|
||||
@@ -205,11 +204,11 @@ static napi_value Init(napi_env env, napi_value exports) {
|
||||
return exports;
|
||||
}
|
||||
|
||||
#else /* !__APPLE__ */
|
||||
#else /* neither __APPLE__ nor __linux__ */
|
||||
|
||||
static napi_value Init(napi_env env, napi_value exports) {
|
||||
/* Frame-copy is macOS-only for now; loading this module elsewhere
|
||||
* yields an empty exports object and the TS side treats it as
|
||||
/* Frame-copy is not ported to this platform yet; loading this module
|
||||
* here yields an empty exports object and the TS side treats it as
|
||||
* unsupported. */
|
||||
(void)env;
|
||||
return exports;
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
it.each<[NodeJS.Platform, string, boolean]>([
|
||||
['darwin', 'arm64', true],
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['win32', 'x64', false],
|
||||
])('%s/%s -> %s', (platform, arch, expected) => {
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
|
||||
@@ -4,11 +4,18 @@ import path from 'path';
|
||||
|
||||
/**
|
||||
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
|
||||
* shared by startup and the runtime service so sandbox and engine decisions
|
||||
* cannot drift. These helpers must remain callable before app.whenReady().
|
||||
* shared by startup, the runtime service, and the frame-copy adapter so
|
||||
* sandbox and engine decisions cannot drift. These helpers must remain
|
||||
* callable before app.whenReady().
|
||||
*
|
||||
* macOS stays Apple-Silicon-only. Linux supports every architecture because
|
||||
* its helper renders through headless EGL and links libmpv out of process.
|
||||
*/
|
||||
export function isFrameCopyPlatformSupported(): boolean {
|
||||
return process.platform === 'darwin' && process.arch === 'arm64';
|
||||
return (
|
||||
process.platform === 'linux' ||
|
||||
(process.platform === 'darwin' && process.arch === 'arm64')
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeDefinedPaths(paths: Array<string | undefined>): string[] {
|
||||
|
||||
@@ -173,4 +173,33 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
const withoutHelper = createAdapter(null);
|
||||
expect(withoutHelper.isSupported()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isSupported platform gate', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||
});
|
||||
|
||||
it.each<[NodeJS.Platform, string, boolean]>([
|
||||
['darwin', 'arm64', true],
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['win32', 'x64', false],
|
||||
])(
|
||||
'on %s/%s with a helper binary present -> %s',
|
||||
(platform, arch, expected) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
expect(adapter.isSupported()).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EmbeddedMpvFrameSource,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
|
||||
import type {
|
||||
NativeEmbeddedMpvAddon,
|
||||
NativeEmbeddedMpvSessionSnapshot,
|
||||
@@ -81,8 +82,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
|
||||
|
||||
isSupported(): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
process.arch === 'arm64' &&
|
||||
isFrameCopyPlatformSupported() &&
|
||||
this.options.resolveHelperPath() !== null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const frameHelperGlSource = readFileSync(
|
||||
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
|
||||
'utf8'
|
||||
);
|
||||
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
|
||||
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
|
||||
);
|
||||
|
||||
function functionBody(name: string): string {
|
||||
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
|
||||
@@ -590,7 +597,7 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
|
||||
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'libmpv-dev mpv pkg-config'
|
||||
'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev'
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'Stage Linux embedded MPV build inputs'
|
||||
@@ -602,11 +609,35 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'Linux embedded MPV addon must not link directly to libmpv'
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper'
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'Linux frame-copy helper must link libmpv'
|
||||
);
|
||||
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
|
||||
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
|
||||
expect(buildScriptSource).toContain('runtimeFiles: []');
|
||||
});
|
||||
|
||||
it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => {
|
||||
expect(buildScriptSource).toContain(
|
||||
"const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';"
|
||||
);
|
||||
expect(buildScriptSource).toContain(
|
||||
'process.env.LINUX_NATIVE_LIBRARY_DIR ||\n defaultLinuxSystemLibDir()'
|
||||
);
|
||||
expect(buildScriptSource).toContain("arm: 'arm-linux-gnueabihf'");
|
||||
expect(buildScriptSource).toContain("arm64: 'aarch64-linux-gnu'");
|
||||
expect(buildScriptSource).toContain("x64: 'x86_64-linux-gnu'");
|
||||
expect(buildScriptSource).toContain(
|
||||
"if (!embeddedMpvRequired && runtime.origin === 'system-dev')"
|
||||
);
|
||||
expect(buildScriptSource).toContain(
|
||||
'removeStaleFrameCopyArtifacts(outputDir);'
|
||||
);
|
||||
});
|
||||
|
||||
it('forces the current frame into a rebuilt shm generation after a paused resize', () => {
|
||||
const runLoop = sourceFunctionBody(
|
||||
frameHelperRenderSource,
|
||||
@@ -620,6 +651,85 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
'if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME))'
|
||||
);
|
||||
});
|
||||
|
||||
it('validates complete EGL candidates and keeps software rendering as the final fallback', () => {
|
||||
const tryCandidate = sourceFunctionBody(
|
||||
linuxFrameHelperGlSource,
|
||||
'bool tryCandidate(',
|
||||
'GlContext::tryCandidate'
|
||||
);
|
||||
const create = sourceFunctionBody(
|
||||
linuxFrameHelperGlSource,
|
||||
'bool create(std::string& errorOut)',
|
||||
'GlContext::create'
|
||||
);
|
||||
|
||||
for (const requiredStep of [
|
||||
'eglInitialize(',
|
||||
'eglBindAPI(EGL_OPENGL_API)',
|
||||
'chooseConfig(',
|
||||
'eglCreateContext(',
|
||||
'eglMakeCurrent(',
|
||||
'glGetString(GL_RENDERER)',
|
||||
]) {
|
||||
expect(tryCandidate).toContain(requiredStep);
|
||||
}
|
||||
expect(create).toContain('softwareFallback');
|
||||
expect(create).toContain('candidate.softwareRenderer');
|
||||
expect(create.indexOf('DisplayTier::SurfacelessMesa')).toBeLessThan(
|
||||
create.indexOf('DisplayTier::Default')
|
||||
);
|
||||
expect(create.indexOf('DisplayTier::Default')).toBeLessThan(
|
||||
create.indexOf('DisplayTier::Gbm')
|
||||
);
|
||||
});
|
||||
|
||||
it('binds the desktop GL API on the render thread and releases it before EGL teardown', () => {
|
||||
const makeCurrent = sourceFunctionBody(
|
||||
linuxFrameHelperGlSource,
|
||||
'bool makeCurrent(std::string& errorOut)',
|
||||
'GlContext::makeCurrent'
|
||||
);
|
||||
const destroy = sourceFunctionBody(
|
||||
linuxFrameHelperGlSource,
|
||||
'void destroy()',
|
||||
'GlContext::destroy'
|
||||
);
|
||||
|
||||
expect(makeCurrent).toContain('eglBindAPI(EGL_OPENGL_API)');
|
||||
expect(makeCurrent).toContain('eglMakeCurrent(');
|
||||
expect(makeCurrent).toContain('return false;');
|
||||
|
||||
const bindIndex = destroy.indexOf('eglBindAPI(EGL_OPENGL_API)');
|
||||
const unbindIndex = destroy.indexOf('eglMakeCurrent(');
|
||||
const releaseIndex = destroy.indexOf('eglReleaseThread()');
|
||||
const destroySurfaceIndex = destroy.indexOf('eglDestroySurface(');
|
||||
expect(bindIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(unbindIndex).toBeGreaterThan(bindIndex);
|
||||
expect(releaseIndex).toBeGreaterThan(unbindIndex);
|
||||
expect(destroySurfaceIndex).toBeGreaterThan(releaseIndex);
|
||||
|
||||
const setupGl = sourceFunctionBody(
|
||||
frameHelperRenderSource,
|
||||
'inline bool RenderPipeline::setupGl(std::string& errorOut)',
|
||||
'RenderPipeline::setupGl'
|
||||
);
|
||||
expect(setupGl).toContain(
|
||||
'if (!gl_.makeCurrent(errorOut)) return false;'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
|
||||
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
|
||||
'dlsym(RTLD_DEFAULT, name)'
|
||||
);
|
||||
const eglIndex = linuxFrameHelperGlSource.indexOf(
|
||||
'eglGetProcAddress(name)'
|
||||
);
|
||||
|
||||
expect(dlsymIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(eglIndex).toBeGreaterThan(dlsymIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Embedded MPV native build configuration', () => {
|
||||
@@ -633,6 +743,10 @@ describe('Embedded MPV native build configuration', () => {
|
||||
(candidate: { target_name?: string }) =>
|
||||
candidate.target_name === 'embedded_mpv'
|
||||
);
|
||||
const helperTarget = bindingGyp.targets.find(
|
||||
(candidate: { target_name?: string }) =>
|
||||
candidate.target_name === 'iptvnator_mpv_helper'
|
||||
);
|
||||
|
||||
it('declares platform-specific native sources for macOS, Windows, and Linux', () => {
|
||||
expect(target).toBeDefined();
|
||||
@@ -640,4 +754,24 @@ describe('Embedded MPV native build configuration', () => {
|
||||
expect(JSON.stringify(target)).toContain('src/embedded_mpv_win32.cc');
|
||||
expect(JSON.stringify(target)).toContain('src/embedded_mpv_linux.cc');
|
||||
});
|
||||
|
||||
it('links libmpv and headless GL only into the Linux helper process', () => {
|
||||
const linuxAddonConfig = target.conditions.find(
|
||||
([condition]: [string]) => condition === 'OS=="linux"'
|
||||
)?.[1];
|
||||
const linuxHelperConfig = helperTarget.conditions.find(
|
||||
([condition]: [string]) => condition === 'OS=="linux"'
|
||||
)?.[1];
|
||||
|
||||
expect(linuxAddonConfig?.libraries).not.toContain('-lmpv');
|
||||
expect(linuxHelperConfig?.libraries).toEqual(
|
||||
expect.arrayContaining([
|
||||
'-lmpv',
|
||||
'-lEGL',
|
||||
'-lOpenGL',
|
||||
'-lgbm',
|
||||
'-ldl',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -297,6 +297,140 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
}
|
||||
);
|
||||
|
||||
describe('frame-copy platform gate', () => {
|
||||
const originalArch = process.arch;
|
||||
|
||||
function mockHelperPresent(): void {
|
||||
jest.spyOn(
|
||||
service as unknown as {
|
||||
resolveFrameCopyHelperPath: () => string | null;
|
||||
},
|
||||
'resolveFrameCopyHelperPath'
|
||||
).mockReturnValue('/native/iptvnator_mpv_helper');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
||||
});
|
||||
|
||||
it('activates the frame-copy engine on Linux, even under native Wayland', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
// Native Wayland session (no X11 ozone): blocks the --wid native
|
||||
// engine, but the frame-copy helper renders offscreen into shm
|
||||
// and must stay available.
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockHelperPresent();
|
||||
|
||||
expect(service.getActiveEngine()).toBe('frame-copy');
|
||||
expect(service.isFrameCopyAvailable()).toBe(true);
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: true,
|
||||
engine: 'frame-copy',
|
||||
frameCopyAvailable: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises frame-copy availability while native Wayland blocks the native engine', () => {
|
||||
// Pre-opt-in discoverability: without frameCopyAvailable on the
|
||||
// unsupported payload the Settings toggle never appears in
|
||||
// exactly the states the frame-copy engine exists to fix.
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
mockHelperPresent();
|
||||
|
||||
const support = service.getSupport();
|
||||
expect(support.supported).toBe(false);
|
||||
expect(support.reason).toContain('Native Wayland embedding');
|
||||
expect(support.frameCopyAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises frame-copy availability when the system mpv executable is missing', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockHelperPresent();
|
||||
|
||||
const support = service.getSupport();
|
||||
expect(support.supported).toBe(false);
|
||||
expect(support.frameCopyAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
|
||||
// The helper links libmpv itself; the mpv-on-PATH probe only
|
||||
// binds the native --wid engine.
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockHelperPresent();
|
||||
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: true,
|
||||
engine: 'frame-copy',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('activates the frame-copy engine on macOS arm64', () => {
|
||||
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockHelperPresent();
|
||||
|
||||
expect(service.getActiveEngine()).toBe('frame-copy');
|
||||
expect(service.isFrameCopyAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockHelperPresent();
|
||||
|
||||
expect(service.getActiveEngine()).toBe('native');
|
||||
expect(service.isFrameCopyAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the native window handle when creating a frame-copy session', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockHelperPresent();
|
||||
const frameCopyAddon = createMockAddon();
|
||||
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
|
||||
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
|
||||
snapshot('loading')
|
||||
);
|
||||
(
|
||||
service as unknown as { frameCopyAdapter: MockAddon }
|
||||
).frameCopyAdapter = frameCopyAddon;
|
||||
|
||||
service.createSession(BOUNDS, '', 1);
|
||||
|
||||
expect(mainWindowGetNativeWindowHandleMock).not.toHaveBeenCalled();
|
||||
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
|
||||
Buffer.alloc(0),
|
||||
BOUNDS,
|
||||
'',
|
||||
1
|
||||
);
|
||||
|
||||
// Dispose while the frame-copy env is still set so teardown
|
||||
// dispatches to the adapter that owns the session, not the
|
||||
// native addon the outer afterEach shutdown would pick.
|
||||
service.disposeSession('s-fc');
|
||||
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
|
||||
's-fc'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not acquire a blocker for a loading session', () => {
|
||||
startSession('s1', snapshot('loading'));
|
||||
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
|
||||
|
||||
@@ -111,9 +111,9 @@ export class EmbeddedMpvNativeService {
|
||||
|
||||
/**
|
||||
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
||||
* Experimental, macOS Apple Silicon only (owner decision 2026-07-10),
|
||||
* opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of
|
||||
* the regular embedded MPV experiment flag.
|
||||
* Experimental, macOS Apple Silicon (owner decision 2026-07-10) and
|
||||
* Linux, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on
|
||||
* top of the regular embedded MPV experiment flag.
|
||||
*/
|
||||
private isFrameCopyEngineRequested(): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
@@ -215,11 +215,21 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isUnsupportedLinuxDisplayServer()) {
|
||||
// The frame-copy engine renders offscreen (headless EGL on Linux)
|
||||
// into a renderer canvas: the Linux X11/Xwayland and system-mpv
|
||||
// requirements below only bind the native --wid engine. Both
|
||||
// native-engine failure returns still advertise frameCopyAvailable
|
||||
// so the Settings toggle stays reachable — otherwise the states the
|
||||
// frame-copy engine exists to fix would hide the way to enable it.
|
||||
if (
|
||||
this.isUnsupportedLinuxDisplayServer() &&
|
||||
!this.isFrameCopyEngineActive()
|
||||
) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
|
||||
frameCopyAvailable: this.isFrameCopyAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,16 +241,6 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
const missingLinuxMpvExecutableReason =
|
||||
this.getMissingLinuxMpvExecutableReason();
|
||||
if (missingLinuxMpvExecutableReason) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingLinuxMpvExecutableReason,
|
||||
};
|
||||
}
|
||||
|
||||
// A requested-but-unavailable frame-copy engine (wrong platform or
|
||||
// missing helper) intentionally falls through to the native path so
|
||||
// embedded MPV keeps working and Settings can clear the opt-in.
|
||||
@@ -254,6 +254,17 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
const missingLinuxMpvExecutableReason =
|
||||
this.getMissingLinuxMpvExecutableReason();
|
||||
if (missingLinuxMpvExecutableReason) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingLinuxMpvExecutableReason,
|
||||
frameCopyAvailable: this.isFrameCopyAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.addon) {
|
||||
try {
|
||||
if (!this.addon.isSupported()) {
|
||||
@@ -385,7 +396,16 @@ export class EmbeddedMpvNativeService {
|
||||
): EmbeddedMpvSession {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
const windowHandle = this.getMainWindowHandle();
|
||||
// The frame-copy adapter ignores the native window handle (frames go
|
||||
// through shm to a DOM canvas), so skip resolving it — under native
|
||||
// Wayland the handle assertion would reject an engine that does not
|
||||
// embed into the window at all. Derive the skip from the dispatched
|
||||
// addon rather than re-evaluating the engine gate, so the two
|
||||
// decisions cannot disagree.
|
||||
const windowHandle =
|
||||
this.frameCopyAdapter && addon === this.frameCopyAdapter
|
||||
? Buffer.alloc(0)
|
||||
: this.getMainWindowHandle();
|
||||
const startedAt = new Date().toISOString();
|
||||
const sessionId = addon.createSession(
|
||||
windowHandle,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
|
||||
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
|
||||
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
|
||||
/**
|
||||
* Embedded MPV frame-copy engine opt-in (macOS arm64). Lives in the main
|
||||
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main
|
||||
* process config file because it must be readable synchronously before the
|
||||
* BrowserWindow is created — the engine relaxes the window sandbox for its
|
||||
* preload frame pump, which cannot change after window creation.
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon وعلى Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon و Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon і Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen. Nur macOS auf Apple Silicon.",
|
||||
"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_RESTART": "Wird nach einem Neustart der App wirksam."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon και Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it. macOS Apple Silicon only.",
|
||||
"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_RESTART": "Takes effect after restarting the app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima. Solo macOS con Apple Silicon.",
|
||||
"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_RESTART": "Se aplica tras reiniciar la aplicación."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus. macOS Apple Silicon 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. macOS Apple Silicon et Linux uniquement.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso. Solo macOS con Apple Silicon.",
|
||||
"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_RESTART": "Ha effetto dopo il riavvio dell'app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSのみ。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSおよびLinuxのみ。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 전용.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 및 Linux 전용.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen. Alleen macOS op Apple Silicon.",
|
||||
"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_RESTART": "Wordt van kracht na het herstarten van de app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim. Tylko macOS na Apple Silicon.",
|
||||
"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_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima. Apenas macOS com Apple Silicon.",
|
||||
"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_RESTART": "Entra em vigor após reiniciar a aplicação."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Что нового",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon и Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir. Yalnızca Apple Silicon macOS.",
|
||||
"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_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS 和 Linux。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS 和 Linux。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -18,10 +18,10 @@ Source files for the embedded MPV integration:
|
||||
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
|
||||
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
|
||||
|
||||
Frame-copy engine sources (experimental, macOS Apple Silicon — see the
|
||||
"Frame-Copy Engine" section below):
|
||||
Frame-copy engine sources (experimental, macOS Apple Silicon and Linux —
|
||||
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_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/src/app/services/embedded-mpv-frame-copy.adapter.ts` — helper-process adapter behind the `NativeEmbeddedMpvAddon` surface.
|
||||
- `apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts` — preload frame pump (shm → WebGL canvas).
|
||||
@@ -49,7 +49,9 @@ Linux native Wayland embedding is not implemented. When Electron is started on X
|
||||
|
||||
## Linux Support Matrix
|
||||
|
||||
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback.
|
||||
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation.
|
||||
|
||||
The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-`mpv`-on-`PATH` probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system `libmpv` and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback.
|
||||
|
||||
When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it.
|
||||
|
||||
@@ -98,16 +100,24 @@ The MPV video surface is a native platform view/window, not a normal DOM element
|
||||
|
||||
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
|
||||
|
||||
## Frame-Copy Engine (Experimental, Apple Silicon Only)
|
||||
## Frame-Copy Engine (Experimental, Apple Silicon and Linux)
|
||||
|
||||
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
|
||||
embedded MPV experiment flag) switches macOS/arm64 to a second rendering
|
||||
engine that replaces the native-view compositing entirely:
|
||||
embedded MPV experiment flag) switches macOS/arm64 and Linux to a second
|
||||
rendering engine that replaces the native-view compositing entirely
|
||||
(gate: `isFrameCopyPlatformSupported()` in
|
||||
`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the
|
||||
service and the adapter):
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a
|
||||
one-process-per-session libmpv host. It decodes (hwdec), renders
|
||||
offscreen at viewport size (headless CGL + async PBO readback ring),
|
||||
publishes BGRA frames into a POSIX shm seqlock ring
|
||||
offscreen at viewport size (async PBO readback ring over a headless GL
|
||||
context — `frame_helper_gl.h`: CGL on macOS; on Linux EGL, acquiring a
|
||||
display in order surfaceless-Mesa → default display → GBM render node;
|
||||
every tier must complete config/context/bind validation, hardware rendering
|
||||
is preferred, and a software tier is retained only as the final fallback),
|
||||
publishes BGRA frames into a POSIX
|
||||
shm seqlock ring
|
||||
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
|
||||
plays audio directly. Control protocol: tab-separated commands on stdin,
|
||||
JSON events on stdout; the `snapshot` event mirrors
|
||||
@@ -175,7 +185,14 @@ ships. The after-pack hook restores the helper's executable mode after the
|
||||
asset copy, and optional/skipped native rebuilds remove stale helper/reader
|
||||
artifacts before reporting frame-copy availability. This cleanup prevents
|
||||
known leftover build output; it is not a compatibility check for a complete
|
||||
but version-mismatched runtime pair.
|
||||
but version-mismatched runtime pair. Linux packages deliberately do NOT ship
|
||||
the helper yet: it links the build host's system `libmpv`, which packaged
|
||||
apps cannot assume is installed, so the centralized after-pack artifact
|
||||
preparation strips both possible helper basenames and package validation
|
||||
rejects either one if it survives. The support probe therefore reports
|
||||
frame-copy unavailable in Linux packages.
|
||||
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
|
||||
lands (PORTING.md milestone 4).
|
||||
|
||||
Trade-offs and constraints:
|
||||
|
||||
@@ -186,12 +203,25 @@ Trade-offs and constraints:
|
||||
before this engine can become a default — candidates: utilityProcess +
|
||||
MessagePort (costs one extra copy + GC churn since Electron ports clone
|
||||
ArrayBuffers) or a WebCodecs-based path.
|
||||
- Scope: Apple Silicon only by owner decision (2026-07-10); Intel Macs
|
||||
keep the native-view engine. Windows/Linux ports of the helper (WGL/EGL)
|
||||
are future work — the shm protocol and adapter are platform-agnostic.
|
||||
- Scope: on macOS Apple Silicon only by owner decision (2026-07-10);
|
||||
Intel Macs keep the native-view engine. Linux (any arch) is ported —
|
||||
headless EGL, works under native Wayland since nothing embeds into a
|
||||
window; dev builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
|
||||
`libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which
|
||||
is legal out-of-process — the in-process libmpv ban still binds the
|
||||
addon). The helper logs the chosen EGL display tier and the GL renderer
|
||||
string to stderr. If an early tier selects Mesa software rendering (for
|
||||
example, while a proprietary NVIDIA driver is reachable through the default
|
||||
display or GBM), it probes the remaining tiers and uses software only when
|
||||
no hardware-backed context works. The Windows port of the helper (WGL) is
|
||||
future work — the shm protocol and adapter are platform-agnostic.
|
||||
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
|
||||
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
|
||||
produce-to-upload latency, zero torn frames over a 10-minute run.
|
||||
Linux (i7-1165G7/Iris Xe, same RESULTS.md): 1080p60 sustained with
|
||||
~1.2 ms copies; the 4K rows are limited by software decode/source
|
||||
generation on that hardware, not by the copy path; zero torn frames
|
||||
everywhere.
|
||||
- Helper crash isolation: an unexpected helper exit surfaces as a session
|
||||
`error` (renderer falls back); it can never take down the Electron main
|
||||
process, unlike in-process libmpv.
|
||||
@@ -301,11 +331,11 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-
|
||||
Current development behavior:
|
||||
|
||||
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
|
||||
- The build script first looks for a staged runtime at `vendor/embedded-mpv/<platform>-<arch>/`.
|
||||
- The staged runtime/build inputs must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
|
||||
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths.
|
||||
- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
|
||||
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
|
||||
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
|
||||
- Linux does not bundle or load `libmpv` in the Electron process. Its native addon still requires staged MPV headers, but runtime support depends on the X11/Xwayland window handle plus an `mpv` executable on `PATH`.
|
||||
- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites.
|
||||
- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
|
||||
|
||||
Current release caveat:
|
||||
@@ -313,7 +343,7 @@ Current release caveat:
|
||||
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
|
||||
- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker.
|
||||
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
|
||||
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present and that no bundled `libmpv.so` files slipped into the package.
|
||||
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`.
|
||||
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
|
||||
|
||||
Before public release, packaging must:
|
||||
@@ -361,7 +391,7 @@ During temporary PR and `master` artifact testing, CI can restore an exact-keyed
|
||||
|
||||
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
|
||||
|
||||
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes the staged MPV headers, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
|
||||
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
|
||||
|
||||
For local development before the vendored runtime exists, Homebrew can be used explicitly:
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ export interface EmbeddedMpvSupport {
|
||||
engine?: EmbeddedMpvEngine;
|
||||
/**
|
||||
* True when this machine could run the frame-copy engine (macOS arm64
|
||||
* with the helper binary present), regardless of whether it is active.
|
||||
* or Linux, with the helper binary present), regardless of whether it
|
||||
* is active.
|
||||
* Drives the Settings toggle; switching engines requires an app restart.
|
||||
*/
|
||||
frameCopyAvailable?: boolean;
|
||||
|
||||
@@ -109,7 +109,7 @@ export interface Settings {
|
||||
/** Custom live recording folder path (uses system Downloads folder if not set) */
|
||||
recordingFolder?: string;
|
||||
/**
|
||||
* Embedded MPV frame-copy engine (experimental, macOS Apple Silicon).
|
||||
* Embedded MPV frame-copy engine (experimental, macOS Apple Silicon and Linux).
|
||||
* Applied on the next app start — the engine relaxes the window sandbox
|
||||
* for its preload frame pump, which is fixed at window creation.
|
||||
*/
|
||||
|
||||
@@ -5,21 +5,26 @@
|
||||
> is the transfer). Written 2026-07-11 by the macOS session that built the
|
||||
> engine; fold into DESIGN.md once both ports land.
|
||||
|
||||
## State as of this handoff
|
||||
## State as of 2026-07-15
|
||||
|
||||
- Branch: `claude/embedded-mpv-frame-copy-6ddc36`, PR:
|
||||
https://github.com/4gray/iptvnator/pull/1169 (CI fully green).
|
||||
- The macOS base shipped through PR #1169 and is now merged into `master`.
|
||||
- The engine works end-to-end on macOS Apple Silicon: Settings toggle →
|
||||
restart → helper process renders mpv offscreen → shm ring → preload pump
|
||||
→ WebGL canvas. Verified live with real IPTV + Stalker VOD.
|
||||
- Scope decision: macOS = arm64 only (Intel Macs keep the native engine).
|
||||
Windows/Linux are NOT ported yet — this branch on those OSes behaves
|
||||
exactly like master (helper doesn't build there, engine can't activate,
|
||||
env flag falls back to native).
|
||||
- Coordination: PR #1169 credits larsemig's idea (#1154 comment 4932807350)
|
||||
and proposes the series merge plan: merge the shared-controls subset
|
||||
(#1148/#1149/#1152–54) rebased, supersede immersive (#1150/#1151) with
|
||||
this engine. **Do not touch the controls layer** until that lands.
|
||||
- **The Linux port below is DONE in PR #1171** (rebased directly onto the
|
||||
merged #1169 result) — headless
|
||||
EGL helper, portable clock, reader on `__linux__`, TS gates, i18n,
|
||||
measurements in RESULTS.md. Verified end-to-end in-app on Ubuntu 25.04
|
||||
(Wayland session) with the xtream mock portal. Dev-build-only on Linux:
|
||||
the helper links system libmpv and `electron-after-pack.cjs` strips it
|
||||
from packages until milestone 4 (Linux bundled-libmpv runtime) — remove
|
||||
that strip when milestone 4 lands. The Windows implementation lives in the
|
||||
follow-up PR #1175; until that PR lands, `master` still has no WGL/named-shm
|
||||
helper and the frame-copy flag falls back to the native engine on Windows.
|
||||
- PR #1169 credits larsemig's idea (#1154 comment 4932807350). Shared-player
|
||||
controls are reviewed as a separate integration after this platform stack;
|
||||
do not conflate that UI layer with the frame-copy transport ports.
|
||||
|
||||
## What "porting" means
|
||||
|
||||
@@ -28,45 +33,47 @@ The stdio protocol, shm layout, TS adapter, main-process service, preload
|
||||
pump, and Angular UI are shared and already shipped.
|
||||
|
||||
```
|
||||
apps/electron-backend/native/helper/
|
||||
apps/electron-backend/native/helper/ # state after the Linux port:
|
||||
├── mpv_frame_helper.cpp # portable: protocol, mpv session, snapshots
|
||||
├── frame_helper_io.h # portable: TSV-in/JSON-out, percent-encoding
|
||||
├── frame_shm.h # layout portable; POSIX shm calls are not
|
||||
└── frame_helper_render.h # macOS-ONLY: CGL headless GL + PBO + shm write
|
||||
├── frame_shm.h # portable layout + shared CLOCK_MONOTONIC clock
|
||||
│ # (POSIX shm calls still need a Windows twin)
|
||||
├── frame_helper_render.h # portable: FBO/PBO readback + shm publish
|
||||
└── frame_helper_gl.h # PLATFORM SEAM: GlContext — CGL (macOS) and
|
||||
# EGL (Linux); Windows adds its WGL twin HERE
|
||||
apps/electron-backend/native/src/embedded_mpv_frame_reader.c
|
||||
# real impl under #ifdef __APPLE__, stub elsewhere
|
||||
# real impl on __APPLE__ + __linux__, stub
|
||||
# elsewhere (Windows needs shm-open/clock twins)
|
||||
```
|
||||
|
||||
Porting = give `frame_helper_render.h` a WGL/EGL twin, give the shm
|
||||
create/open a Windows twin, flip the TS gates, extend packaging.
|
||||
Porting Windows = give `frame_helper_gl.h` a WGL GlContext twin, give the
|
||||
shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
|
||||
`embedded-mpv-frame-copy-platform.util.ts`, extend packaging.
|
||||
|
||||
## Branching & merge strategy (do this, not "commit to the current branch")
|
||||
## Branching & merge strategy
|
||||
|
||||
- **Never commit port work into `claude/embedded-mpv-frame-copy-6ddc36`** —
|
||||
that branch backs PR #1169, which is a frozen review/testing target
|
||||
(review fixes only).
|
||||
- Create a port branch off it: `claude/frame-copy-linux-port` (Windows
|
||||
later: branch off the Linux port branch if it reuses its portable
|
||||
clock/shm refactors — likely yes — otherwise off the frame-copy branch).
|
||||
- Open the port PR with **base = the frame-copy branch**, so the diff shows
|
||||
only the port. Merge order: #1169 → Linux PR → Windows PR. When #1169
|
||||
merges and its branch is deleted, GitHub retargets the stacked PR to
|
||||
master automatically.
|
||||
- Keep the stack at most one unmerged level deep; if #1169 gains review
|
||||
commits, rebase the port branch onto it early and often.
|
||||
- Merge order is #1169 → #1171 → #1175. #1169 is already merged; #1171 is
|
||||
based directly on that `master`, while #1175 remains stacked on the Linux
|
||||
port until #1171 lands.
|
||||
- Rewrite only the platform-specific commit range when moving a stacked PR;
|
||||
do not replay the old parent history after its squash merge. Retarget the PR
|
||||
explicitly and keep the parent branch until its child has been rewritten.
|
||||
- Keep the stack at most one unmerged level deep. New follow-up work branches
|
||||
from the latest landed platform base on `master`.
|
||||
- Commit incrementally within the port branch; land each platform's
|
||||
measurement rows in `RESULTS.md` in the same PR as its port.
|
||||
- If #1169 has already merged by the time you read this: branch off
|
||||
`master` instead and ignore the retargeting notes.
|
||||
|
||||
## Per-platform task lists
|
||||
|
||||
### Linux (do first — much closer to done)
|
||||
### Linux (DONE 2026-07-11 — see the update in "State" above)
|
||||
|
||||
1. **Render backend**: headless EGL (`EGL_PLATFORM_SURFACELESS_MESA` /
|
||||
`eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA)` with fallback to
|
||||
GBM) + the same FBO/PBO/readback code. GL entry points via
|
||||
`eglGetProcAddress` in mpv's `get_proc_address`.
|
||||
default-display and GBM candidates) + the same FBO/PBO/readback code.
|
||||
Each candidate is validated through context bind and `GL_RENDERER`; a
|
||||
hardware renderer wins over an earlier software tier. mpv resolves linked
|
||||
core GL symbols through `dlsym(RTLD_DEFAULT)` and falls back to
|
||||
`eglGetProcAddress` for extensions.
|
||||
2. **shm**: POSIX `shm_open` works as-is. The ONLY blocker in shared code:
|
||||
`clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)` is **macOS-only** — replace
|
||||
with a portable `now_ns()` (`clock_gettime(CLOCK_MONOTONIC, ...)`) in
|
||||
@@ -201,8 +208,8 @@ create/open a Windows twin, flip the TS gates, extend packaging.
|
||||
|
||||
## Suggested milestone order
|
||||
|
||||
1. Linux helper bring-up (EGL + portable clock) → lavfi smoke → in-app
|
||||
behind flag → measure.
|
||||
1. **Completed in #1171:** Linux helper bring-up (EGL + portable clock) →
|
||||
lavfi smoke → in-app behind flag → measure.
|
||||
2. Windows helper bring-up (WGL, named shm, reader twin) → same ladder →
|
||||
**iGPU laptop numbers = the decisive open gate**.
|
||||
3. Packaging: per-platform artifact validation + runtime staging.
|
||||
|
||||
@@ -23,7 +23,9 @@ helper and the `Electron Helper (Renderer)` process during playback.
|
||||
Column meanings: *new fps* — frames actually reaching the canvas; *copy* —
|
||||
shm→ArrayBuffer memcpy in the addon; *upload* — `texSubImage2D` wall time;
|
||||
*age* — produce→uploaded latency (helper memcpy done → texture updated,
|
||||
same CLOCK_MONOTONIC_RAW clock).
|
||||
same monotonic clock on both sides — CLOCK_MONOTONIC_RAW in the original
|
||||
spike harness used for the M1 rows; the production engine uses
|
||||
CLOCK_MONOTONIC via `frame_shm_now_ns()` since the Linux port).
|
||||
|
||||
## MacBook Pro M1 Pro (arm64), macOS, 120 Hz internal display — 2026-07-10
|
||||
|
||||
@@ -107,6 +109,40 @@ would not have been representative. The macOS hardware gate is therefore
|
||||
closed by the M1 Pro numbers above; the remaining risk hardware is
|
||||
Windows/Linux.
|
||||
|
||||
## Linux mid-range laptop (iGPU) — Ubuntu 25.04, i7-1165G7 / Iris Xe, x64 — 2026-07-11
|
||||
|
||||
Source: Linux port branch (headless-EGL `frame_helper_gl.h` backend), system
|
||||
libmpv 2.5.0 (mpv 0.40), Mesa 25.0 iris. Measured with the production helper
|
||||
binary + `embedded_mpv_frame_reader.node` in a Node probe loop
|
||||
(`linux-frame-probe.mjs` in this directory — the spike viewer harness is
|
||||
macOS-only), so *age* here is produce→reader-copy and excludes the renderer
|
||||
texture upload. hwdec was NOT active — this machine
|
||||
has no VAAPI driver installed (`intel-media-va-driver`), so HEVC rows are
|
||||
software decode; treat them as a decode-limited floor, not a pipeline
|
||||
ceiling.
|
||||
|
||||
| Scenario | New fps | copy ms avg/p95 | age ms avg/p95 | torn |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1080p60 testsrc2, sw | 60.1 | 1.16 / 1.37 | 2.26 / 3.21 | 0 |
|
||||
| 4K60 testsrc2, sw | 50.0 | 5.75 / 6.92 | 7.22 / 8.00 | 0 |
|
||||
| 4K60 HEVC 25 Mbit, sw decode | 39.9 | 7.62 / 14.6 | 9.06 / 17.0 | 0 |
|
||||
| 4K60 HEVC 25 Mbit in a 1280×720 viewport | 53.1 | 0.94 / 2.57 | 2.42 / 5.28 | 0 |
|
||||
|
||||
Readings:
|
||||
|
||||
- 1080p60 — the realistic viewport class for this laptop's 1920×1200
|
||||
screen — holds a clean 60 fps with ~1 ms copies.
|
||||
- The 4K rows are stress rows: producers are limited by software
|
||||
decode/source generation on 4 cores, not by the copy path (copy stays
|
||||
well under one 60 Hz frame budget even at full 4K).
|
||||
- The viewport-size claim reproduces on Linux: the same 4K60 HEVC clip in a
|
||||
720p viewport drops the copy from 7.6 ms to 0.94 ms and lifts fps from
|
||||
~40 to ~53 (remaining gap = software decode).
|
||||
- torn=0 across every run; the aspect-fit generation bump was verified
|
||||
separately (4:3 source in a 16:9 viewport → `-g2` at 960×720).
|
||||
- EGL display tier used: Mesa surfaceless platform (first tier; no display
|
||||
server needed).
|
||||
|
||||
## Windows mid-range laptop (iGPU) — PENDING
|
||||
|
||||
Blocked on the Windows helper port (WGL or D3D11 readback path).
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Linux frame-copy probe: spawns iptvnator_mpv_helper, attaches the
|
||||
* embedded_mpv_frame_reader addon to the announced shm generation, and
|
||||
* reports producer fps, copy latency (ageMs), copy wall time, torn reads
|
||||
* and pixel spread. Usage:
|
||||
* node frame-probe.mjs <url> <width> <height> <seconds> [--hwdec auto]
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const releaseDir = process.env.RELEASE_DIR;
|
||||
const [url, width = '1280', height = '720', seconds = '5', ...rest] =
|
||||
process.argv.slice(2);
|
||||
if (!url || !releaseDir) {
|
||||
console.error('usage: RELEASE_DIR=<native build dir> node frame-probe.mjs <url> [w] [h] [s]');
|
||||
process.exit(1);
|
||||
}
|
||||
const hwdecIdx = rest.indexOf('--hwdec');
|
||||
const hwdec = hwdecIdx >= 0 ? rest[hwdecIdx + 1] : null;
|
||||
|
||||
const reader = require(path.join(releaseDir, 'embedded_mpv_frame_reader.node'));
|
||||
const helperPath = path.join(releaseDir, 'iptvnator_mpv_helper');
|
||||
const shmBase = `/impv-probe-${process.pid}`;
|
||||
|
||||
const args = ['--shm-base', shmBase, '--width', width, '--height', height];
|
||||
if (hwdec) args.push('--hwdec', hwdec);
|
||||
const child = spawn(helperPath, args, { stdio: ['pipe', 'pipe', 'inherit'] });
|
||||
|
||||
let buffer = '';
|
||||
let currentSource = null;
|
||||
const generations = [];
|
||||
let statusLog = [];
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
let i;
|
||||
while ((i = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, i);
|
||||
buffer = buffer.slice(i + 1);
|
||||
if (!line.trim()) continue;
|
||||
let ev;
|
||||
try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (ev.event === 'shm') {
|
||||
generations.push(`${ev.name} ${ev.width}x${ev.height}`);
|
||||
currentSource = ev;
|
||||
} else if (ev.event === 'snapshot') {
|
||||
if (statusLog[statusLog.length - 1] !== ev.status) statusLog.push(ev.status);
|
||||
} else if (ev.event === 'fatal') {
|
||||
console.error('FATAL:', ev.error);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
await sleep(300);
|
||||
child.stdin.write(`load\turl=${url.replace(/%/g, '%25')}\n`);
|
||||
|
||||
// Wait for playback + a possibly-resized generation to settle.
|
||||
const settleDeadline = Date.now() + 10000;
|
||||
while (Date.now() < settleDeadline) {
|
||||
await sleep(100);
|
||||
if (statusLog.includes('playing') && currentSource) {
|
||||
await sleep(700); // allow the aspect-fit resize generation to land
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!currentSource) {
|
||||
console.error('no shm source announced');
|
||||
child.kill('SIGKILL');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const info = reader.open(currentSource.name);
|
||||
console.log(`attached ${currentSource.name}: ${info.width}x${info.height} stride=${info.stride} gen=${info.generation}`);
|
||||
|
||||
const frameBuf = new ArrayBuffer(info.frameBytes);
|
||||
let lastSeq = 0;
|
||||
let frames = 0;
|
||||
let torn = 0;
|
||||
const ages = [];
|
||||
const copyTimes = [];
|
||||
const durationMs = Number(seconds) * 1000;
|
||||
const start = Date.now();
|
||||
let pixelMin = 255, pixelMax = 0;
|
||||
|
||||
while (Date.now() - start < durationMs) {
|
||||
const seq = reader.latestSeq();
|
||||
if (seq !== lastSeq) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const result = reader.copyLatest(frameBuf);
|
||||
const t1 = process.hrtime.bigint();
|
||||
if (result) {
|
||||
frames += 1;
|
||||
lastSeq = result.seq;
|
||||
ages.push(result.ageMs);
|
||||
copyTimes.push(Number(t1 - t0) / 1e6);
|
||||
if (result.torn) torn += 1;
|
||||
}
|
||||
}
|
||||
await sleep(2);
|
||||
}
|
||||
const elapsed = (Date.now() - start) / 1000;
|
||||
|
||||
// pixel spread over the last frame
|
||||
const view = new Uint8Array(frameBuf);
|
||||
for (let i = 0; i < view.length; i += 4001 * 4) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const v = view[i + c];
|
||||
if (v < pixelMin) pixelMin = v;
|
||||
if (v > pixelMax) pixelMax = v;
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (arr, p) => {
|
||||
if (!arr.length) return NaN;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
||||
};
|
||||
const avg = (arr) => arr.reduce((a, b) => a + b, 0) / (arr.length || 1);
|
||||
|
||||
console.log(`generations seen: ${generations.join(' | ')}`);
|
||||
console.log(`status transitions: ${statusLog.join(' -> ')}`);
|
||||
console.log(`producerAliveMs: ${reader.producerAliveMs().toFixed(1)}`);
|
||||
console.log(`new frames: ${frames} in ${elapsed.toFixed(1)}s = ${(frames / elapsed).toFixed(1)} fps`);
|
||||
console.log(`copy ms avg/p95: ${avg(copyTimes).toFixed(2)} / ${pct(copyTimes, 0.95).toFixed(2)}`);
|
||||
console.log(`age ms avg/p95: ${avg(ages).toFixed(2)} / ${pct(ages, 0.95).toFixed(2)}`);
|
||||
console.log(`torn: ${torn}`);
|
||||
console.log(`pixel spread (BGR sampled): min=${pixelMin} max=${pixelMax}`);
|
||||
|
||||
child.stdin.write('quit\n');
|
||||
child.stdin.end();
|
||||
await sleep(500);
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
reader.close();
|
||||
@@ -1,6 +1,6 @@
|
||||
# Embedded MPV Runtime
|
||||
|
||||
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime.
|
||||
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.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
@@ -128,7 +128,7 @@ The build manifest records source URLs, downloaded archive SHA-256 values where
|
||||
|
||||
## Build Integration
|
||||
|
||||
`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`.
|
||||
`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged (or system) MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and the addon must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`. The frame-copy helper executable built by the same run is the inverse: CI verifies it DOES link `libmpv` (separate process).
|
||||
|
||||
For local macOS development with Homebrew `mpv`, use:
|
||||
|
||||
@@ -138,7 +138,7 @@ pnpm run serve:backend:embedded-mpv
|
||||
|
||||
The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest.
|
||||
|
||||
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`.
|
||||
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`, and the hook strips `iptvnator_mpv_helper` from Linux packages (it links the build host's system libmpv; the frame-copy engine stays dev-build-only on Linux until bundled-runtime staging lands).
|
||||
|
||||
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that macOS/Windows packages use a `vendored-lgpl` runtime/build input set. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. Linux artifacts verify that the addon and `external-mpv-process` manifest are present, that no bundled `libmpv.so` files are present, and the runtime support check verifies that `mpv` is available on `PATH`.
|
||||
|
||||
@@ -148,4 +148,4 @@ Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that mu
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -497,6 +497,11 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
|
||||
false,
|
||||
'Linux packages must omit the unsupported frame-copy helper'
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(windowsHelperPath),
|
||||
false,
|
||||
'Linux packages must omit stale Windows frame-copy helpers too'
|
||||
);
|
||||
|
||||
fs.writeFileSync(helperPath, '#!/bin/sh\n');
|
||||
removeStaleFrameCopyArtifacts(tempDir);
|
||||
@@ -618,3 +623,36 @@ test('embedded MPV package validation rejects bundled Linux libmpv', () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded MPV package validation rejects frame-copy helpers in Linux packages', () => {
|
||||
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: 'external-mpv-process' })
|
||||
);
|
||||
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper'), '');
|
||||
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), '');
|
||||
|
||||
const errors = validatePackagedEmbeddedMpv(tempDir, {
|
||||
platform: 'linux',
|
||||
required: true,
|
||||
});
|
||||
|
||||
const message = errors.join('\n');
|
||||
assert.match(message, /must not ship frame-copy helpers/);
|
||||
assert.match(message, /iptvnator_mpv_helper\n/);
|
||||
assert.match(message, /iptvnator_mpv_helper\.exe/);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,19 @@ const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe';
|
||||
const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node';
|
||||
|
||||
function preparePackagedFrameCopyArtifacts(nativeDir, platform) {
|
||||
if (platform === 'linux') {
|
||||
// Until Linux ships a bundled libmpv runtime, do not package a helper
|
||||
// linked against the build host's system library. Remove both names
|
||||
// so a stale cross-platform build artifact cannot leak into a package.
|
||||
for (const fileName of [
|
||||
FRAME_COPY_HELPER,
|
||||
WINDOWS_FRAME_COPY_HELPER,
|
||||
]) {
|
||||
fs.rmSync(path.join(nativeDir, fileName), { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const helperPath = path.join(
|
||||
nativeDir,
|
||||
platform === 'win32'
|
||||
@@ -13,13 +26,6 @@ function preparePackagedFrameCopyArtifacts(nativeDir, platform) {
|
||||
: FRAME_COPY_HELPER
|
||||
);
|
||||
|
||||
if (platform === 'linux') {
|
||||
// Until Linux ships a bundled libmpv runtime, do not package a helper
|
||||
// linked against the build host's system library.
|
||||
fs.rmSync(helperPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform !== 'win32' && fs.existsSync(helperPath)) {
|
||||
// Asset copying drops POSIX modes; restore spawn permission.
|
||||
fs.chmodSync(helperPath, 0o755);
|
||||
|
||||
@@ -572,6 +572,22 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const packagedFrameCopyHelpers = [
|
||||
path.join(unpackedNativeDir, 'iptvnator_mpv_helper'),
|
||||
path.join(unpackedNativeDir, 'iptvnator_mpv_helper.exe'),
|
||||
].filter((candidate) => fs.existsSync(candidate));
|
||||
if (packagedFrameCopyHelpers.length > 0) {
|
||||
errors.push(
|
||||
[
|
||||
'Linux packages must not ship frame-copy helpers linked against the build host system libmpv.',
|
||||
'Remove:',
|
||||
...packagedFrameCopyHelpers.map(
|
||||
(candidate) => `- ${candidate}`
|
||||
),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
const bundledLinuxRuntime = [
|
||||
path.join(libDir, 'libmpv.so.2'),
|
||||
path.join(libDir, 'libmpv.so.1'),
|
||||
|
||||
Reference in new issue
Block a user