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:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-15 20:42:50 +02:00
1 parent 26271fc076
commit 7d75d989e8
45 files changed
+1387 -177

No files matched your search

+64 -3
View File
@@ -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}.`);
+36
View File
@@ -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;
@@ -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.
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon وعلى Linux.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon و Linux.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon і Linux.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen. Nur macOS auf Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon και Linux.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it. macOS Apple Silicon 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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima. Solo macOS con Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus. macOS Apple Silicon 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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso. Solo macOS con Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSのみ。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSおよびLinuxのみ。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 전용.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 및 Linux 전용.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen. Alleen macOS op Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim. Tylko macOS na Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima. Apenas macOS com Apple Silicon.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon и Linux.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir. Yalnızca Apple Silicon macOS.",
"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": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS 和 Linux。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
},
"THEMES": {
+1 -1
View File
@@ -430,7 +430,7 @@
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS。",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS 和 Linux。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
},
"THEMES": {