feat(embedded-mpv): frame-copy rendering engine (experimental, macOS Apple Silicon) (#1169)

* spike(embedded-mpv): frame-copy pipeline prototype (helper + shm ring + Electron viewer)

Standalone macOS spike for the frame-copy unification direction from the
2026-07-10 analysis: a helper process renders mpv offscreen into a GL FBO,
reads frames back through an async PBO ring, and publishes BGRA frames into
a 3-slot POSIX shm seqlock ring; a minimal Electron viewer copies the newest
frame via a plain-C N-API addon and uploads it to a WebGL canvas per rAF.

First numbers on M1 Pro (see spike README): 4K60 HEVC hwdec sustained at
60 fps end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload age, zero torn frames. Remaining gates: weak hardware,
long-run pacing, HDR, latency flash test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): add RESULTS.md measurement log with M1 Pro baseline

Structured per-machine table with repro commands so the pending Intel Mac
and Windows iGPU runs can be appended and compared one-to-one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): pacing/judder instrumentation + 50/25 fps and HDR gate results

Viewer now measures inter-frame intervals on both clocks (present side and
producer side): stddev/p99/max, late-frame counters vs the producer's median
interval, and a cumulative LONGRUN summary every 30 s. The addon exposes the
producer timestamp (produceMs) for this.

Measured on M1 Pro: 50 fps and 25 fps cadences are clean (late frames only
at startup; residual jitter is 120 Hz rAF grid quantization, bounded by one
display tick), and 4K25 HDR10 PQ/BT.2020 is tonemapped to SDR by mpv before
readback at full rate with unchanged copy costs. RESULTS.md carries the
tables and HDR-clip repro commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): record 10-minute 4K60 HEVC long-run results

Zero dropped frames and zero torn reads after the first-minute warmup over
~8.5 minutes; steady-state late frames (~0.4%) track the 12 s test clip's
--loop restarts, not the copy pipeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): viewport-scaling measurement + integration design draft

Confirms the render-at-viewport-size claim (4K source in a 720p FBO costs
720p: 0.17 ms readback / 0.16 ms copy / 0.17 ms upload at 60 fps) and adds
DESIGN.md — the draft integration architecture: per-session helper process
linking bundled libmpv on all platforms (finally full-featured + Wayland-
agnostic Linux), JSON-over-stdio control evolving the Linux wid protocol,
unchanged EmbeddedMpvSession renderer contract, shm generations for resize,
packaging via the existing vendored-runtime tooling, rollout behind its own
flag with the docked path as default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): auto-detect Homebrew prefix and Node headers for Intel Macs

BREW_PREFIX was hardcoded to /opt/homebrew (Apple Silicon) and NODE_INC to
one nvm version; both now resolve via brew --prefix and the PATH node's
execPath, so the pending Intel Mac run needs no Makefile edits. README gets
a fresh-machine checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): self-contained measurement bundle for machines without Node/pnpm

make-bundle.sh assembles a tarball with the spike sources, vendored N-API
headers (Makefile prefers them when present, so no Node install is needed),
pre-generated 4K HEVC/HDR10 test clips, and an official Electron dist
download for the target arch. collect-results.sh builds and runs the full
RESULTS.md scenario suite automatically (plus an optional --long 10-minute
run) and writes one results-<host>-<date>.txt to send back. Target-machine
prerequisites shrink to Xcode CLT + brew mpv — built for the pending Intel
Mac baseline run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): support MacPorts libmpv and legacy-macOS bundles

Makefile and collect-results.sh now detect libmpv in the Homebrew prefix or
MacPorts /opt/local (Homebrew is unsupported on legacy macOS like High
Sierra; 'sudo port install mpv +libmpv' provides libmpv there). make-bundle
takes ELECTRON_VERSION/BUNDLE_SUFFIX overrides — Electron 27+ needs macOS
10.15, so High Sierra bundles ship Electron 26.6.10 (LSMinimumSystemVersion
10.13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): scope macOS frame-copy engine to Apple Silicon only

Owner decision 2026-07-10: skip Intel Mac measurements and gate the future
frame-copy engine on arm64. Intel Macs able to run the app at all are a
shrinking 2015-2020 cohort and keep the docked/external/web player paths;
the macOS hardware gate closes with the M1 Pro numbers, and remaining
hardware risk moves to the Windows/Linux ports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy helper process and shm frame reader (native layer)

iptvnator_mpv_helper: one-process-per-session libmpv host that renders
offscreen at viewport size (headless CGL + async PBO ring, validated in
spikes/mpv-frame-copy), publishes BGRA frames into a seqlock shm ring with
resize generations, plays audio directly, and speaks a stdio protocol —
tab-separated commands in, JSON events out. The snapshot event mirrors
NativeEmbeddedMpvSessionSnapshot; status semantics (END_FILE reasons,
eof-reached with keep-open, pause gated on loaded path, fatal-only status
flips) are ported from embedded_mpv.mm.

embedded_mpv_frame_reader.node: plain-C N-API reader the preload script
uses to memcpy the newest complete frame into a V8 ArrayBuffer (Electron's
memory cage forbids zero-copy). Stub exports off macOS.

Both build as extra binding.gyp targets through build-embedded-mpv.js; the
helper gets the same libmpv dependency-path rewrite + ad-hoc re-sign as the
addon and is validated by the forbidden-link check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy engine wiring in main process and preload

EmbeddedMpvFrameCopyAdapter implements the NativeEmbeddedMpvAddon surface
over a per-session helper process (spawn, stdio protocol, snapshot cache,
graceful quit->SIGTERM->SIGKILL teardown), so EmbeddedMpvNativeService
reuses its polling/diff/power-blocker/recording logic unchanged. The
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY flag (darwin/arm64 only) routes
getAddon() to the adapter and reports engine: 'frame-copy' in support.

The preload frame pump loads the shm reader addon, copies the newest frame
once per rAF into a reused buffer, and uploads it to WebGL2 on the
renderer's canvas — no frame data crosses the contextBridge; the bridge
only gains attachEmbeddedMpvFrameView/detachEmbeddedMpvFrameView. The
experiment flag relaxes the window sandbox for that native require;
contextIsolation and nodeIntegration:false stay on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy canvas mode in the player component + docs

EmbeddedMpvPlayerComponent renders <canvas data-embedded-mpv-frame> when
support reports engine 'frame-copy' and the session controller starts/stops
the preload frame pump around the session lifecycle. The bounds provider
skips HIDDEN_BOUNDS and the popover cutout for this engine — the canvas is
ordinary DOM, dialogs and popovers stack above it natively; bounds sync
still drives the helper's render size. Adapter unit tests cover spawn args,
snapshot caching, shm generations, protocol encoding, unexpected-exit
mapping, and dispose escalation. Architecture doc and CLAUDE.md describe
the engine, its flag, and the sandbox trade-off.

Verified end to end in the built app (M1 Pro): engine detection, helper
spawn, lavfi playback onto the canvas via CDP-injected smoke — including an
orientation fix (helper FLIP_Y already yields texture-order rows; the pump
shader must not flip uv again).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): close helper stdin on dispose + lifecycle logging

Live testing surfaced a stray idle helper that survived a session switch;
until the root cause is pinned down, dispose now also closes the child's
stdin (the helper exits on EOF) as a second kill path besides quit ->
SIGTERM -> SIGKILL, and spawn/dispose/exit are logged with the session id
so leaks are attributable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): reap sessions when the renderer reloads or crashes

Root cause of the stray idle helper found during live testing: session
teardown lives in the renderer's Angular lifecycle, which never runs on a
renderer crash or hard reload — the main process kept the session (and its
frame-copy helper process / native mpv handle) alive until app shutdown.
EmbeddedMpvNativeService now watches the main window's webContents for
render-process-gone and did-navigate (full reloads only; in-app Angular
routing emits did-navigate-in-page) and disposes every session. Applies to
both engines. Verified live: location.reload() during frame-copy playback
logs 'Disposing 1 session(s): renderer reloaded' and the helper exits
cleanly. Regression test drives both events against the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): Settings toggle for the frame-copy engine

New 'Embedded MPV: frame-copy engine' checkbox in Settings > Playback,
shown only when the machine can run it (macOS arm64 with the helper binary
present — support now reports frameCopyAvailable). The choice persists to
the main-process config store because the engine relaxes the window sandbox
for the preload frame pump, which is fixed at window creation: main.ts
reads the store before creating the window and sets the engine env var; an
explicitly set env var (including '0') always wins, and the UI shows a
restart hint while the saved choice differs from the active engine.
Localized in all 18 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): aspect-fit rendering in the frame-copy helper

The helper now observes dwidth/dheight and renders its FBO at the
aspect-fit size of the video inside the requested viewport, bumping a shm
generation on change — letterbox bars are never baked into frames (the VOD
watch shell's ~2:1 box no longer shows black side bars; the canvas
background is transparent so the sides show the app surface, while
fullscreen keeps its black backdrop). Frames also get smaller than the
viewport when aspects differ, trimming copy cost. Aspect override changes
refit automatically. Snapshots now carry videoWidth/videoHeight, and the
adapter forwards IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY to mpv's audio-delay
for lip-sync tuning until proper calibration lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): require frame-copy artifacts in macOS package validation + docs

macOS packages that ship embedded_mpv.node must also ship the
iptvnator_mpv_helper binary and the embedded_mpv_frame_reader.node addon —
they come out of the same binding.gyp run, and a package missing them would
silently lose the frame-copy engine. Covered in the package-identity test.
Architecture doc and CLAUDE.md document the Settings toggle, aspect-fit
rendering, audio-delay passthrough, and the renderer-reload session reaping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(electron): inline TS helpers so the sandboxed preload keeps working

The frame pump's async/await (target es2015 + importHelpers) made webpack
externalize tslib in main.preload.js. Sandboxed preloads can only require
Electron's built-in module whitelist, so the entire preload script failed
to load and window.electron disappeared for every run without the
frame-copy flag. importHelpers:false for electron-backend keeps the preload
bundle self-contained — and future async code in preload can no longer
silently reintroduce the breakage. Verified live: sandboxed run now has the
bridge, reports engine 'native' and frameCopyAvailable true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): commit the frame-copy analysis handoff + source inventory

The 2026-07-10 analysis that led to this branch now lives next to the spike
(spikes/mpv-frame-copy/ANALYSIS.md), and the architecture doc's What To
Commit section lists the frame-copy engine sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address review findings on the frame-copy engine

- Stale pump attach can no longer win over a newer session: attach/detach
  bump a shared epoch and async attach waits re-check it after every await,
  so an attach for a replaced session aborts instead of installing itself
  (greptile P1).
- A failed frame-view attach (no canvas, no WebGL2, reader missing) now
  disposes the session and surfaces the error UI instead of leaving audio
  playing behind a black canvas (codex P2).
- A stale frame-copy opt-in without the helper binary falls back to the
  native engine instead of reporting embedded MPV unsupported, and the
  Settings checkbox stays visible while a saved opt-in exists so it can
  always be cleared (codex P2). Regression test covers the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(packaging): make the darwin frame-copy packaging test host-agnostic

On non-macOS CI hosts validatePackagedEmbeddedMpv also reports that macOS
link validation needs a macOS host, so the success-path assertion now
checks only the frame-copy artifact requirement instead of expecting an
empty error list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): Windows/Linux porting handoff for the frame-copy engine

Self-contained entry point for porting sessions on other machines: current
state and coordination constraints, per-OS task lists (Linux EGL first,
then Windows WGL + named shm — the decisive iGPU perf gate), the
hard-won gotchas from the macOS integration (preload/tslib sandbox
breakage, V8 memory cage, frame orientation, stale-attach epoch, dispose
escalation, node-gyp naming, snapshot protocol semantics), testing
recipes, and the suggested milestone order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): branching and merge strategy in the porting handoff

Port work goes to stacked branches off the frame-copy branch (PR base =
frame-copy branch, sequential merges, stack depth one), never into the
frozen PR #1169 branch itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): drop stale uncommitted note from porting handoff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): harden frame-copy helper startup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-15 19:48:07 +02:00
1 parent eb1bfaa474
commit 26271fc076
80 files changed
+5963 -89

No files matched your search

@@ -7,6 +7,9 @@ const {
patchAddonForBundledRuntime,
validateNoForbiddenRuntimeLinks,
} = require('../../tools/packaging/embedded-mpv-packaging.cjs');
const {
removeStaleFrameCopyArtifacts,
} = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs');
const workspaceRoot = process.cwd();
const addonRoot = path.join(
@@ -59,6 +62,9 @@ function log(message) {
function cleanOutput() {
fs.rmSync(outputFile, { force: true });
fs.rmSync(outputLibDir, { recursive: true, force: true });
// Do not let an optional/skipped rebuild leave frame-copy artifacts that
// could make startup treat an incomplete runtime as available.
removeStaleFrameCopyArtifacts(outputDir);
for (const windowsDllName of [
'mpv-2.dll',
'libmpv-2.dll',
@@ -473,8 +479,16 @@ function main() {
if (targetPlatform === 'darwin') {
patchAddonForBundledRuntime(outputFile, outputLibDir);
// The frame-copy helper executable links libmpv too and sits next to
// the same lib/ directory, so it gets the identical dependency-path
// rewrite + ad-hoc re-sign.
const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper');
if (fs.existsSync(frameHelperFile)) {
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
}
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
...runtimeManifest.dylibs.map((dylib) =>
path.join(outputLibDir, dylib)
),
+44
View File
@@ -75,6 +75,50 @@
}
]
]
},
{
"target_name": "embedded_mpv_frame_reader",
"sources": [
"src/embedded_mpv_frame_reader.c"
],
"include_dirs": [
"helper"
]
},
{
"target_name": "iptvnator_mpv_helper",
"type": "none",
"conditions": [
[
"OS==\"mac\"",
{
"type": "executable",
"sources": [
"helper/mpv_frame_helper.cpp"
],
"include_dirs": [
"<!(node -p \"process.env.LIBMPV_INCLUDE_DIR || '/opt/homebrew/include'\")",
"helper"
],
"cflags_cc": [
"-std=c++17"
],
"xcode_settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "c++17",
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"MACOSX_DEPLOYMENT_TARGET": "11.0",
"OTHER_LDFLAGS": [
"-Wl,-rpath,@executable_path/lib",
"-Wl,-rpath,@loader_path/lib"
]
},
"libraries": [
"-framework OpenGL",
"<!(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'))\")"
]
}
]
]
}
]
}
@@ -0,0 +1,176 @@
/*
* stdio line protocol for the frame-copy helper.
*
* Inbound commands (stdin): one command per line, tab-separated fields.
* The first field is the command name; the rest are `key=value` pairs with
* percent-encoded values (%XX for at least '%', TAB, CR, LF). This avoids a
* JSON parser in native code — the TypeScript adapter owns serialization.
*
* Outbound events (stdout): one JSON object per line. Emitting JSON only
* needs string escaping, which lives here too.
*/
#pragma once
#include <cstdint>
#include <cstdio>
#include <mutex>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
namespace frame_helper {
inline int hexValue(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
inline std::string percentDecode(const std::string& value) {
std::string out;
out.reserve(value.size());
for (size_t i = 0; i < value.size(); i++) {
if (value[i] == '%' && i + 2 < value.size() &&
hexValue(value[i + 1]) >= 0 && hexValue(value[i + 2]) >= 0) {
out.push_back(
(char)((hexValue(value[i + 1]) << 4) | hexValue(value[i + 2])));
i += 2;
continue;
}
out.push_back(value[i]);
}
return out;
}
struct Command {
std::string name;
std::unordered_map<std::string, std::string> args;
std::string get(const std::string& key,
const std::string& fallback = "") const {
const auto it = args.find(key);
return it == args.end() ? fallback : it->second;
}
double getDouble(const std::string& key, double fallback) const {
const auto it = args.find(key);
if (it == args.end()) return fallback;
try {
return std::stod(it->second);
} catch (...) {
return fallback;
}
}
};
inline Command parseCommandLine(const std::string& line) {
Command command;
std::stringstream stream(line);
std::string field;
bool first = true;
while (std::getline(stream, field, '\t')) {
if (first) {
command.name = field;
first = false;
continue;
}
const size_t eq = field.find('=');
if (eq == std::string::npos) continue;
command.args[field.substr(0, eq)] =
percentDecode(field.substr(eq + 1));
}
return command;
}
inline std::string jsonEscape(const std::string& value) {
std::string out;
out.reserve(value.size() + 8);
for (const char c : value) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out.push_back(c);
}
}
}
return out;
}
/* Minimal JSON writer for flat/one-level-nested event objects. */
class JsonWriter {
public:
JsonWriter() { buffer_ += '{'; }
JsonWriter& str(const std::string& key, const std::string& value) {
writeKey(key);
buffer_ += '"';
buffer_ += jsonEscape(value);
buffer_ += '"';
return *this;
}
JsonWriter& num(const std::string& key, double value) {
writeKey(key);
char buf[64];
std::snprintf(buf, sizeof(buf), "%.6g", value);
buffer_ += buf;
return *this;
}
JsonWriter& boolean(const std::string& key, bool value) {
writeKey(key);
buffer_ += value ? "true" : "false";
return *this;
}
JsonWriter& nullValue(const std::string& key) {
writeKey(key);
buffer_ += "null";
return *this;
}
JsonWriter& raw(const std::string& key, const std::string& rawJson) {
writeKey(key);
buffer_ += rawJson;
return *this;
}
std::string finish() {
buffer_ += '}';
return buffer_;
}
private:
void writeKey(const std::string& key) {
if (!first_) buffer_ += ',';
first_ = false;
buffer_ += '"';
buffer_ += jsonEscape(key);
buffer_ += "\":";
}
std::string buffer_;
bool first_ = true;
};
/* stdout is shared by every thread that emits events. */
inline void emitLine(const std::string& jsonLine) {
static std::mutex stdoutMutex;
std::lock_guard<std::mutex> lock(stdoutMutex);
std::fwrite(jsonLine.data(), 1, jsonLine.size(), stdout);
std::fputc('\n', stdout);
std::fflush(stdout);
}
} // namespace frame_helper
@@ -0,0 +1,407 @@
/*
* Offscreen render + readback pipeline for the frame-copy helper (macOS).
*
* 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).
*
* 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>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <functional>
#include <mutex>
#include <string>
#include "frame_helper_io.h"
#include "frame_shm.h"
namespace frame_helper {
inline uint64_t nowNs() { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); }
struct ShmRing {
FrameShmHeader* header = nullptr;
uint8_t* base = nullptr;
size_t size = 0;
std::string name;
bool create(const std::string& shmName, int width, int height,
uint32_t generation) {
destroy();
shm_unlink(shmName.c_str());
const int fd = shm_open(shmName.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd < 0) return false;
const uint64_t frameBytes = (uint64_t)width * 4u * (uint64_t)height;
const uint64_t dataOffset =
(sizeof(FrameShmHeader) + FRAME_SHM_DATA_ALIGN - 1) &
~(uint64_t)(FRAME_SHM_DATA_ALIGN - 1);
const size_t total =
(size_t)(dataOffset + FRAME_SHM_RING_SLOTS * frameBytes);
if (ftruncate(fd, (off_t)total) != 0) {
close(fd);
shm_unlink(shmName.c_str());
return false;
}
void* mapped =
mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
if (mapped == MAP_FAILED) {
shm_unlink(shmName.c_str());
return false;
}
std::memset(mapped, 0, sizeof(FrameShmHeader));
auto* hdr = static_cast<FrameShmHeader*>(mapped);
hdr->version = FRAME_SHM_VERSION;
hdr->width = (uint32_t)width;
hdr->height = (uint32_t)height;
hdr->stride = (uint32_t)width * 4u;
hdr->generation = generation;
hdr->frame_bytes = frameBytes;
hdr->data_offset = dataOffset;
std::atomic_thread_fence(std::memory_order_release);
hdr->magic = FRAME_SHM_MAGIC;
header = hdr;
base = static_cast<uint8_t*>(mapped);
size = total;
name = shmName;
return true;
}
uint8_t* slotData(uint64_t seq) const {
return base + header->data_offset +
(seq % FRAME_SHM_RING_SLOTS) * header->frame_bytes;
}
void destroy() {
if (base) {
munmap(base, size);
shm_unlink(name.c_str());
}
header = nullptr;
base = nullptr;
size = 0;
name.clear();
}
};
class RenderPipeline {
public:
/* Called from the render thread after a resize created a new shm
* generation, so the main protocol layer can announce it. */
std::function<void(const std::string& name, int width, int height,
uint32_t generation)>
onGenerationChanged;
bool start(mpv_handle* mpv, const std::string& shmBaseName, int width,
int height, std::string& errorOut);
void requestResize(int width, int height);
void stop();
void notifyUpdate(); /* mpv render update callback -> wake render loop */
void runLoop(); /* render thread body */
mpv_render_context* renderContext() { return renderContext_; }
/* 0 = initializing, 1 = ready, -1 = failed */
int initState() const { return initState_.load(); }
private:
bool setupGl(std::string& errorOut);
bool rebuildTargets(int width, int height);
void publishPending();
void renderFrame();
mpv_handle* mpv_ = nullptr;
CGLContextObj cgl_ = nullptr;
mpv_render_context* renderContext_ = nullptr;
void* glDylib_ = nullptr;
std::string shmBaseName_;
ShmRing ring_;
uint32_t generation_ = 0;
int width_ = 0;
int height_ = 0;
GLuint texture_ = 0;
GLuint fbo_ = 0;
GLuint pbos_[FRAME_SHM_RING_SLOTS] = {0};
int cursor_ = 0;
int pendingPbo_ = -1;
uint64_t nextSeq_ = 1;
std::mutex mutex_;
std::condition_variable cv_;
bool updatePending_ = false;
bool stopRequested_ = false;
int pendingWidth_ = 0;
int pendingHeight_ = 0;
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) {
mpv_ = 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;
}
inline bool RenderPipeline::setupGl(std::string& errorOut) {
CGLSetCurrentContext(cgl_);
if (!rebuildTargets(width_, height_)) {
errorOut = "framebuffer setup failed";
return false;
}
mpv_opengl_init_params glInit = {frameHelperGetProcAddress, glDylib_};
mpv_render_param params[] = {
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
const int result = mpv_render_context_create(&renderContext_, mpv_, params);
if (result < 0) {
errorOut = mpv_error_string(result);
return false;
}
return true;
}
inline bool RenderPipeline::rebuildTargets(int width, int height) {
if (texture_) glDeleteTextures(1, &texture_);
if (fbo_) glDeleteFramebuffers(1, &fbo_);
if (pbos_[0]) glDeleteBuffers(FRAME_SHM_RING_SLOTS, pbos_);
pendingPbo_ = -1;
cursor_ = 0;
width_ = width;
height_ = height;
const GLsizeiptr frameBytes = (GLsizeiptr)width * 4 * height;
glGenTextures(1, &texture_);
glBindTexture(GL_TEXTURE_2D, texture_);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glGenFramebuffers(1, &fbo_);
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
texture_, 0);
glGenBuffers(FRAME_SHM_RING_SLOTS, pbos_);
for (GLuint pbo : pbos_) {
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
glBufferData(GL_PIXEL_PACK_BUFFER, frameBytes, nullptr, GL_STREAM_READ);
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
return false;
}
generation_ += 1;
const std::string shmName =
shmBaseName_ + "-g" + std::to_string(generation_);
if (!ring_.create(shmName, width, height, generation_)) {
return false;
}
if (onGenerationChanged) {
onGenerationChanged(shmName, width, height, generation_);
}
return true;
}
inline void RenderPipeline::notifyUpdate() {
{
std::lock_guard<std::mutex> lock(mutex_);
updatePending_ = true;
}
cv_.notify_all();
}
inline void RenderPipeline::requestResize(int width, int height) {
{
std::lock_guard<std::mutex> lock(mutex_);
pendingWidth_ = width;
pendingHeight_ = height;
updatePending_ = true;
}
cv_.notify_all();
}
inline void RenderPipeline::stop() {
{
std::lock_guard<std::mutex> lock(mutex_);
stopRequested_ = true;
}
cv_.notify_all();
}
inline void RenderPipeline::publishPending() {
if (pendingPbo_ < 0 || !ring_.header) return;
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos_[pendingPbo_]);
const void* mapped = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0,
(GLsizeiptr)ring_.header->frame_bytes,
GL_MAP_READ_BIT);
if (mapped) {
const uint64_t seq = nextSeq_++;
FrameShmSlot& slot =
ring_.header->slots[seq % FRAME_SHM_RING_SLOTS];
slot.seq.store(0, std::memory_order_release);
std::memcpy(ring_.slotData(seq), mapped,
(size_t)ring_.header->frame_bytes);
slot.produce_time_ns = nowNs();
slot.seq.store(seq, std::memory_order_release);
ring_.header->latest_seq.store(seq, std::memory_order_release);
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
pendingPbo_ = -1;
}
inline void RenderPipeline::renderFrame() {
mpv_opengl_fbo fbo = {(int)fbo_, width_, height_, 0};
int flipY = 1;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flipY},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(renderContext_, params);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_);
glReadBuffer(GL_COLOR_ATTACHMENT0);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
publishPending();
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos_[cursor_]);
glReadPixels(0, 0, width_, height_, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
nullptr);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
pendingPbo_ = cursor_;
cursor_ = (cursor_ + 1) % FRAME_SHM_RING_SLOTS;
mpv_render_context_report_swap(renderContext_);
}
inline void RenderPipeline::runLoop() {
std::string glError;
if (!setupGl(glError)) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "render init failed: " + glError)
.finish());
initState_.store(-1);
return;
}
initState_.store(1);
while (true) {
int resizeWidth = 0;
int resizeHeight = 0;
bool targetsRebuilt = false;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_for(lock, std::chrono::milliseconds(100), [&] {
return updatePending_ || stopRequested_;
});
if (stopRequested_) break;
updatePending_ = false;
resizeWidth = pendingWidth_;
resizeHeight = pendingHeight_;
pendingWidth_ = 0;
pendingHeight_ = 0;
}
if (resizeWidth > 0 && resizeHeight > 0 &&
(resizeWidth != width_ || resizeHeight != height_)) {
if (!rebuildTargets(resizeWidth, resizeHeight)) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "resize target rebuild failed")
.finish());
break;
}
targetsRebuilt = true;
}
const uint64_t flags = mpv_render_context_update(renderContext_);
/* A resize installs an empty shm ring. Redraw the current frame even
* while paused; the async PBO is published on the next loop. */
if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME)) {
renderFrame();
} else {
/* Flush a stranded readback so pause keeps the last frame. */
publishPending();
}
if (ring_.header) {
ring_.header->heartbeat_ns.store(nowNs(),
std::memory_order_relaxed);
}
}
mpv_render_context_set_update_callback(renderContext_, nullptr, nullptr);
mpv_render_context_free(renderContext_);
renderContext_ = nullptr;
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_);
ring_.destroy();
}
} // namespace frame_helper
@@ -0,0 +1,45 @@
/*
* Shared-memory frame ring for the frame-copy embedded MPV helper.
*
* Same triple-buffer seqlock protocol as spikes/mpv-frame-copy (validated
* there on M1 Pro up to 4K60), plus a `generation` field: every viewport
* 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).
*/
#pragma once
#include <stdint.h>
#ifdef __cplusplus
#include <atomic>
typedef std::atomic<uint64_t> frame_shm_atomic_u64;
#else
#include <stdatomic.h>
typedef _Atomic uint64_t frame_shm_atomic_u64;
#endif
#define FRAME_SHM_MAGIC 0x564d5046u /* 'FPMV' */
#define FRAME_SHM_VERSION 1u
#define FRAME_SHM_RING_SLOTS 3u
#define FRAME_SHM_DATA_ALIGN 4096u
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 */
} FrameShmSlot;
typedef struct {
uint32_t magic; /* written last during init; readers gate on it */
uint32_t version;
uint32_t width;
uint32_t height;
uint32_t stride; /* bytes per row, tightly packed BGRA */
uint32_t generation;
uint64_t frame_bytes;
uint64_t data_offset;
frame_shm_atomic_u64 latest_seq; /* newest complete frame, 0 = none */
frame_shm_atomic_u64 heartbeat_ns;
FrameShmSlot slots[FRAME_SHM_RING_SLOTS];
} FrameShmHeader;
@@ -0,0 +1,745 @@
/*
* iptvnator-mpv-helper — frame-copy embedded MPV helper process (macOS).
*
* One process = one playback session. Owns libmpv end to end: decodes,
* renders offscreen at viewport size, publishes BGRA frames into a shared
* memory ring (frame_shm.h), and plays audio directly through the OS.
*
* Control protocol: tab-separated commands on stdin, JSON events on stdout
* (see frame_helper_io.h). The `snapshot` event mirrors the TypeScript
* NativeEmbeddedMpvSessionSnapshot shape so the Electron-side adapter can
* cache it verbatim. Status semantics are ported from embedded_mpv.mm:
* END_FILE reason mapping, eof-reached => ended (keep-open), pause flag
* gated on a loaded path, only fatal/load errors flip the status.
*
* Usage:
* iptvnator_mpv_helper --shm-base /impv-<id> --width 1280 --height 720
* [--volume 0..1] [--hwdec auto]
*/
#include <mpv/client.h>
#include <algorithm>
#include <atomic>
#include <clocale>
#include <cmath>
#include <csignal>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "frame_helper_io.h"
#include "frame_helper_render.h"
namespace {
using frame_helper::Command;
using frame_helper::emitLine;
using frame_helper::JsonWriter;
using frame_helper::RenderPipeline;
struct TrackInfo {
int64_t id = -1;
std::string title;
std::string language;
bool defaultTrack = false;
bool forced = false;
};
struct SnapshotState {
std::string status = "idle";
double positionSeconds = 0;
double durationSeconds = -1; /* <0 => null */
double volume = 1; /* 0..1 */
int64_t videoWidth = 0; /* mpv dwidth/dheight; 0 = unknown */
int64_t videoHeight = 0;
std::string streamUrl;
std::vector<TrackInfo> audioTracks;
int64_t selectedAudioTrackId = -1;
std::vector<TrackInfo> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1;
std::string aspectOverride = "no";
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
std::string recordingError;
std::string error;
};
struct HelperState {
mpv_handle* mpv = nullptr;
RenderPipeline pipeline;
std::mutex mutex;
SnapshotState snapshot;
bool paused = false;
bool loadedPath = false;
bool dirty = true;
std::string lastEmittedStatus;
uint64_t lastEmitNs = 0;
/* Requested viewport in device pixels; the render target is the
* aspect-fit of the video inside it so letterbox bars are never
* rendered (and frames stay as small as possible). */
int viewportWidth = 0;
int viewportHeight = 0;
std::atomic<bool> running{true};
};
HelperState g_state;
constexpr uint64_t SNAPSHOT_EMIT_INTERVAL_NS = 250ull * 1000 * 1000;
std::string isoTimestampNow() {
char buffer[32];
const time_t now = time(nullptr);
struct tm utc;
gmtime_r(&now, &utc);
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
return buffer;
}
std::string tracksJson(const std::vector<TrackInfo>& tracks,
int64_t selectedId) {
std::string out = "[";
bool first = true;
for (const TrackInfo& track : tracks) {
JsonWriter writer;
writer.num("id", (double)track.id);
if (!track.title.empty()) writer.str("title", track.title);
if (!track.language.empty()) writer.str("language", track.language);
writer.boolean("selected", track.id == selectedId);
writer.boolean("defaultTrack", track.defaultTrack);
writer.boolean("forced", track.forced);
if (!first) out += ',';
first = false;
out += writer.finish();
}
out += ']';
return out;
}
/* Compose the snapshot event. Caller holds g_state.mutex. */
std::string composeSnapshotLocked() {
const SnapshotState& s = g_state.snapshot;
JsonWriter writer;
writer.str("event", "snapshot");
writer.str("status", s.status);
writer.num("positionSeconds", std::max(0.0, s.positionSeconds));
if (s.videoWidth > 0 && s.videoHeight > 0) {
writer.num("videoWidth", (double)s.videoWidth);
writer.num("videoHeight", (double)s.videoHeight);
}
if (s.durationSeconds >= 0) {
writer.num("durationSeconds", s.durationSeconds);
} else {
writer.nullValue("durationSeconds");
}
writer.num("volume", std::clamp(s.volume, 0.0, 1.5));
writer.str("streamUrl", s.streamUrl);
writer.raw("audioTracks", tracksJson(s.audioTracks, s.selectedAudioTrackId));
if (s.selectedAudioTrackId >= 0) {
writer.num("selectedAudioTrackId", (double)s.selectedAudioTrackId);
} else {
writer.nullValue("selectedAudioTrackId");
}
writer.raw("subtitleTracks",
tracksJson(s.subtitleTracks, s.selectedSubtitleTrackId));
if (s.selectedSubtitleTrackId >= 0) {
writer.num("selectedSubtitleTrackId",
(double)s.selectedSubtitleTrackId);
} else {
writer.nullValue("selectedSubtitleTrackId");
}
writer.num("playbackSpeed", s.playbackSpeed);
writer.str("aspectOverride", s.aspectOverride);
JsonWriter recording;
recording.boolean("active", s.recordingActive);
if (!s.recordingTargetPath.empty())
recording.str("targetPath", s.recordingTargetPath);
if (!s.recordingStartedAt.empty())
recording.str("startedAt", s.recordingStartedAt);
if (!s.recordingError.empty()) recording.str("error", s.recordingError);
writer.raw("recording", recording.finish());
if (!s.error.empty()) writer.str("error", s.error);
return writer.finish();
}
/* Emit the snapshot when dirty: immediately on status changes, otherwise at
* most every SNAPSHOT_EMIT_INTERVAL_NS. Called from the mpv event thread. */
void maybeEmitSnapshot(bool force = false) {
std::string line;
{
std::lock_guard<std::mutex> lock(g_state.mutex);
if (!g_state.dirty && !force) return;
const uint64_t now = frame_helper::nowNs();
const bool statusChanged =
g_state.snapshot.status != g_state.lastEmittedStatus;
if (!force && !statusChanged &&
now - g_state.lastEmitNs < SNAPSHOT_EMIT_INTERVAL_NS) {
return;
}
g_state.dirty = false;
g_state.lastEmitNs = now;
g_state.lastEmittedStatus = g_state.snapshot.status;
line = composeSnapshotLocked();
}
emitLine(line);
}
void markDirtyLocked() { g_state.dirty = true; }
/* Aspect-fit of the current video inside the requested viewport. Caller
* holds g_state.mutex. Letterbox bars are handled by the renderer layout,
* not baked into frames. */
std::pair<int, int> computeRenderSizeLocked() {
const int vpW = g_state.viewportWidth;
const int vpH = g_state.viewportHeight;
const int64_t vw = g_state.snapshot.videoWidth;
const int64_t vh = g_state.snapshot.videoHeight;
if (vw < 16 || vh < 16 || vpW < 16 || vpH < 16) {
return {vpW, vpH};
}
const double scale =
std::min(vpW / (double)vw, vpH / (double)vh);
const int width =
std::max(16, (int)llround((double)vw * scale)) & ~1;
const int height =
std::max(16, (int)llround((double)vh * scale)) & ~1;
return {width, height};
}
void applyRenderSizeLocked() {
const auto [width, height] = computeRenderSizeLocked();
if (width >= 16 && height >= 16) {
g_state.pipeline.requestResize(width, height);
}
}
/* ---- track-list parsing (ported from embedded_mpv.mm) ------------------- */
void parseTrackNode(const mpv_node& trackNode, const char* wantedType,
std::vector<TrackInfo>& out) {
if (trackNode.format != MPV_FORMAT_NODE_MAP || !trackNode.u.list) return;
TrackInfo track;
bool matchesType = false;
const mpv_node_list& map = *trackNode.u.list;
for (int i = 0; i < map.num; i++) {
const char* key = map.keys[i];
const mpv_node& value = map.values[i];
if (!key) continue;
if (std::strcmp(key, "type") == 0 &&
value.format == MPV_FORMAT_STRING && value.u.string) {
matchesType = std::strcmp(value.u.string, wantedType) == 0;
} else if (std::strcmp(key, "id") == 0 &&
value.format == MPV_FORMAT_INT64) {
track.id = value.u.int64;
} else if (std::strcmp(key, "title") == 0 &&
value.format == MPV_FORMAT_STRING && value.u.string) {
track.title = value.u.string;
} else if (std::strcmp(key, "lang") == 0 &&
value.format == MPV_FORMAT_STRING && value.u.string) {
track.language = value.u.string;
} else if (std::strcmp(key, "default") == 0 &&
value.format == MPV_FORMAT_FLAG) {
track.defaultTrack = value.u.flag != 0;
} else if (std::strcmp(key, "forced") == 0 &&
value.format == MPV_FORMAT_FLAG) {
track.forced = value.u.flag != 0;
}
}
if (matchesType && track.id >= 0) {
out.push_back(std::move(track));
}
}
void updateTracksFromNode(const mpv_node& trackListNode) {
if (trackListNode.format != MPV_FORMAT_NODE_ARRAY ||
!trackListNode.u.list) {
return;
}
std::vector<TrackInfo> audio;
std::vector<TrackInfo> subs;
for (int i = 0; i < trackListNode.u.list->num; i++) {
parseTrackNode(trackListNode.u.list->values[i], "audio", audio);
parseTrackNode(trackListNode.u.list->values[i], "sub", subs);
}
g_state.snapshot.audioTracks = std::move(audio);
g_state.snapshot.subtitleTracks = std::move(subs);
}
bool parseTrackSelection(const char* value, int64_t& out) {
if (!value) return false;
char* end = nullptr;
const long long parsed = strtoll(value, &end, 10);
if (end == value || (end && *end != '\0')) return false;
out = parsed;
return true;
}
/* ---- mpv event loop (status semantics ported from embedded_mpv.mm) ------ */
void handlePropertyChange(const mpv_event_property& property) {
const std::string name = property.name ? property.name : "";
SnapshotState& s = g_state.snapshot;
if (name == "time-pos" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.positionSeconds = *static_cast<double*>(property.data);
} else if (name == "duration" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.durationSeconds = *static_cast<double*>(property.data);
} else if (name == "pause" && property.format == MPV_FORMAT_FLAG &&
property.data) {
g_state.paused = *static_cast<int*>(property.data) != 0;
if (s.status != "loading" && s.status != "ended" &&
s.status != "error" && g_state.loadedPath) {
s.status = g_state.paused ? "paused" : "playing";
}
} else if (name == "eof-reached" && property.format == MPV_FORMAT_FLAG &&
property.data) {
if (*static_cast<int*>(property.data) != 0 && g_state.loadedPath) {
s.status = "ended";
}
} else if (name == "volume" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.volume = *static_cast<double*>(property.data) / 100.0;
} else if (name == "path" && property.format == MPV_FORMAT_STRING &&
property.data) {
const char* value = *static_cast<char**>(property.data);
s.streamUrl = value ? value : "";
} else if (name == "track-list" && property.format == MPV_FORMAT_NODE &&
property.data) {
updateTracksFromNode(*static_cast<mpv_node*>(property.data));
} else if (name == "aid" && property.format == MPV_FORMAT_STRING &&
property.data) {
int64_t selected = -1;
if (parseTrackSelection(*static_cast<char**>(property.data),
selected)) {
s.selectedAudioTrackId = selected;
} else {
s.selectedAudioTrackId = -1;
}
} else if (name == "sid" && property.format == MPV_FORMAT_STRING &&
property.data) {
int64_t selected = -1;
if (parseTrackSelection(*static_cast<char**>(property.data),
selected)) {
s.selectedSubtitleTrackId = selected;
} else {
s.selectedSubtitleTrackId = -1;
}
} else if (name == "speed" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.playbackSpeed = *static_cast<double*>(property.data);
} else if ((name == "dwidth" || name == "dheight") &&
property.format == MPV_FORMAT_INT64 && property.data) {
const int64_t value = *static_cast<int64_t*>(property.data);
if (name == "dwidth") {
s.videoWidth = value;
} else {
s.videoHeight = value;
}
applyRenderSizeLocked();
} else if (name == "video-aspect-override" &&
property.format == MPV_FORMAT_STRING && property.data) {
const char* value = *static_cast<char**>(property.data);
/* mpv reports the unset override as "-1.000000"; the renderer
* contract uses "no" for that state. */
s.aspectOverride =
value && *value && std::atof(value) > 0 ? value : "no";
} else {
return;
}
markDirtyLocked();
}
void runMpvEventLoop() {
while (g_state.running.load()) {
mpv_event* event = mpv_wait_event(g_state.mpv, 0.1);
if (!event) continue;
if (event->event_id == MPV_EVENT_NONE) {
maybeEmitSnapshot();
continue;
}
{
std::lock_guard<std::mutex> lock(g_state.mutex);
SnapshotState& s = g_state.snapshot;
switch (event->event_id) {
case MPV_EVENT_START_FILE:
s.status = "loading";
s.error.clear();
s.audioTracks.clear();
s.selectedAudioTrackId = -1;
s.subtitleTracks.clear();
s.selectedSubtitleTrackId = -1;
g_state.loadedPath = false;
markDirtyLocked();
break;
case MPV_EVENT_FILE_LOADED:
g_state.loadedPath = true;
s.status = g_state.paused ? "paused" : "playing";
markDirtyLocked();
break;
case MPV_EVENT_END_FILE: {
const auto* endFile =
static_cast<mpv_event_end_file*>(event->data);
if (endFile &&
endFile->reason == MPV_END_FILE_REASON_ERROR) {
s.status = "error";
s.error = endFile->error < 0
? mpv_error_string(endFile->error)
: "Playback failed.";
} else if (endFile &&
endFile->reason == MPV_END_FILE_REASON_EOF &&
g_state.running.load()) {
s.status = "ended";
} else if (endFile &&
endFile->reason ==
MPV_END_FILE_REASON_REDIRECT &&
g_state.running.load()) {
s.status = "loading";
s.error.clear();
g_state.loadedPath = false;
} else if (g_state.running.load()) {
s.status = "idle";
}
markDirtyLocked();
break;
}
case MPV_EVENT_PROPERTY_CHANGE: {
const auto* property =
static_cast<mpv_event_property*>(event->data);
if (property) handlePropertyChange(*property);
break;
}
case MPV_EVENT_LOG_MESSAGE: {
const auto* logMessage =
static_cast<mpv_event_log_message*>(event->data);
if (!logMessage || !logMessage->level ||
!logMessage->text) {
break;
}
const std::string level = logMessage->level;
if (level == "error" || level == "fatal") {
s.error = logMessage->text;
markDirtyLocked();
}
if (level == "fatal") {
s.status = "error";
}
emitLine(JsonWriter()
.str("event", "log")
.str("level", level)
.str("prefix", logMessage->prefix
? logMessage->prefix
: "mpv")
.str("text", logMessage->text)
.finish());
break;
}
case MPV_EVENT_SHUTDOWN:
g_state.running.store(false);
s.status = "closed";
markDirtyLocked();
break;
default:
break;
}
}
maybeEmitSnapshot();
}
maybeEmitSnapshot(true);
}
/* ---- command handling ---------------------------------------------------- */
void setPropertyString(const char* name, const std::string& value) {
mpv_set_property_string(g_state.mpv, name, value.c_str());
}
void handleLoadCommand(const Command& command) {
const std::string url = command.get("url");
if (url.empty()) return;
/* Loading a replacement stream stops any active recording first,
* mirroring the .mm behavior. */
{
std::lock_guard<std::mutex> lock(g_state.mutex);
if (g_state.snapshot.recordingActive) {
setPropertyString("stream-record", "");
g_state.snapshot.recordingActive = false;
g_state.snapshot.recordingStartedAt.clear();
}
g_state.snapshot.streamUrl = url;
g_state.snapshot.error.clear();
g_state.snapshot.status = "loading";
g_state.dirty = true;
}
/* Generic pass-through: every `opt.<name>` argument becomes a loadfile
* option, so the adapter controls start/user-agent/referrer/headers
* without protocol changes. */
std::string optionString;
for (const auto& [key, value] : command.args) {
if (key.rfind("opt.", 0) != 0 || value.empty()) continue;
if (!optionString.empty()) optionString += ',';
std::string escaped = value;
/* loadfile options string uses %len%value quoting to stay safe */
optionString += key.substr(4) + "=%" +
std::to_string(escaped.size()) + "%" + escaped;
}
const char* args[] = {"loadfile", url.c_str(), "replace",
optionString.empty() ? nullptr : optionString.c_str(),
nullptr};
/* mpv >= 0.38 expects an index argument before options; use the
* options-map-free form: loadfile <url> replace [options] */
int result;
if (optionString.empty()) {
const char* plain[] = {"loadfile", url.c_str(), "replace", nullptr};
result = mpv_command(g_state.mpv, plain);
} else {
const char* withOpts[] = {"loadfile", url.c_str(), "replace", "-1",
optionString.c_str(), nullptr};
result = mpv_command(g_state.mpv, withOpts);
if (result == MPV_ERROR_INVALID_PARAMETER) {
/* Older mpv without the index argument. */
result = mpv_command(g_state.mpv, args);
}
}
if (result < 0) {
std::lock_guard<std::mutex> lock(g_state.mutex);
g_state.snapshot.status = "error";
g_state.snapshot.error = mpv_error_string(result);
g_state.dirty = true;
}
}
void handleRecordCommand(const Command& command) {
const std::string path = command.get("path");
const int result = mpv_set_property_string(g_state.mpv, "stream-record",
path.c_str());
std::lock_guard<std::mutex> lock(g_state.mutex);
if (result < 0) {
g_state.snapshot.recordingError = mpv_error_string(result);
} else if (path.empty()) {
g_state.snapshot.recordingActive = false;
g_state.snapshot.recordingStartedAt.clear();
g_state.snapshot.recordingError.clear();
} else {
g_state.snapshot.recordingActive = true;
g_state.snapshot.recordingTargetPath = path;
g_state.snapshot.recordingStartedAt = isoTimestampNow();
g_state.snapshot.recordingError.clear();
}
g_state.dirty = true;
}
void handleCommand(const Command& command) {
if (command.name == "load") {
handleLoadCommand(command);
} else if (command.name == "pause") {
const int flag = command.get("value") == "1" ? 1 : 0;
mpv_set_property(g_state.mpv, "pause", MPV_FORMAT_FLAG,
const_cast<int*>(&flag));
} else if (command.name == "seek") {
const double seconds = command.getDouble("seconds", 0);
const std::string value = std::to_string(seconds);
const char* args[] = {"seek", value.c_str(), "absolute", nullptr};
mpv_command(g_state.mpv, args);
} else if (command.name == "volume") {
double percent =
std::clamp(command.getDouble("value", 1) * 100.0, 0.0, 100.0);
mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE, &percent);
} else if (command.name == "aid" || command.name == "sid") {
const std::string value = command.get("value");
setPropertyString(command.name.c_str(),
value == "-1" ? "no" : value);
} else if (command.name == "speed") {
double speed = std::clamp(command.getDouble("value", 1), 0.25, 4.0);
mpv_set_property(g_state.mpv, "speed", MPV_FORMAT_DOUBLE, &speed);
} else if (command.name == "aspect") {
setPropertyString("video-aspect-override", command.get("value", "no"));
} else if (command.name == "record") {
handleRecordCommand(command);
} else if (command.name == "size") {
const int width = (int)command.getDouble("width", 0);
const int height = (int)command.getDouble("height", 0);
if (width >= 16 && height >= 16) {
std::lock_guard<std::mutex> lock(g_state.mutex);
g_state.viewportWidth = width;
g_state.viewportHeight = height;
applyRenderSizeLocked();
}
} else if (command.name == "quit") {
g_state.running.store(false);
mpv_wakeup(g_state.mpv);
}
}
void runStdinLoop() {
std::string line;
while (g_state.running.load() && std::getline(std::cin, line)) {
if (line.empty()) continue;
handleCommand(frame_helper::parseCommandLine(line));
}
/* stdin EOF => the parent process died or closed us: shut down. */
g_state.running.store(false);
mpv_wakeup(g_state.mpv);
}
void onRenderUpdate(void* pipeline) {
static_cast<RenderPipeline*>(pipeline)->notifyUpdate();
}
struct HelperArgs {
std::string shmBase = "/impv";
std::string hwdec = "auto";
std::string audioDelay; /* seconds, mpv audio-delay passthrough */
int width = 1280;
int height = 720;
double volume = 1;
};
HelperArgs parseArgs(int argc, char** argv) {
HelperArgs args;
for (int i = 1; i < argc; i++) {
const std::string arg = argv[i];
auto next = [&]() -> std::string {
return i + 1 < argc ? argv[++i] : "";
};
if (arg == "--shm-base") args.shmBase = next();
else if (arg == "--width") args.width = std::atoi(next().c_str());
else if (arg == "--height") args.height = std::atoi(next().c_str());
else if (arg == "--volume") args.volume = std::atof(next().c_str());
else if (arg == "--hwdec") args.hwdec = next();
else if (arg == "--audio-delay") args.audioDelay = next();
}
args.width = std::max(16, args.width);
args.height = std::max(16, args.height);
return args;
}
} // namespace
int main(int argc, char** argv) {
std::setlocale(LC_NUMERIC, "C");
signal(SIGPIPE, SIG_IGN);
const HelperArgs args = parseArgs(argc, argv);
g_state.mpv = mpv_create();
if (!g_state.mpv) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "mpv_create failed")
.finish());
return 1;
}
mpv_set_option_string(g_state.mpv, "vo", "libmpv");
mpv_set_option_string(g_state.mpv, "hwdec", args.hwdec.c_str());
mpv_set_option_string(g_state.mpv, "keep-open", "yes");
mpv_set_option_string(g_state.mpv, "idle", "yes");
mpv_set_option_string(g_state.mpv, "input-default-bindings", "no");
mpv_set_option_string(g_state.mpv, "osc", "no");
if (!args.audioDelay.empty()) {
/* Compensates the frame-copy video-path latency to restore
* lip-sync; calibrated per platform, see the architecture doc. */
mpv_set_option_string(g_state.mpv, "audio-delay",
args.audioDelay.c_str());
}
if (mpv_initialize(g_state.mpv) < 0) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", "mpv_initialize failed")
.finish());
return 1;
}
mpv_request_log_messages(g_state.mpv, "warn");
double initialVolumePercent =
std::clamp(args.volume, 0.0, 1.0) * 100.0;
mpv_set_property(g_state.mpv, "volume", MPV_FORMAT_DOUBLE,
&initialVolumePercent);
mpv_observe_property(g_state.mpv, 1, "time-pos", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 2, "duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 3, "pause", MPV_FORMAT_FLAG);
mpv_observe_property(g_state.mpv, 4, "volume", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 5, "path", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 6, "track-list", MPV_FORMAT_NODE);
mpv_observe_property(g_state.mpv, 7, "aid", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 8, "sid", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 9, "speed", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 10, "video-aspect-override",
MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 11, "eof-reached", MPV_FORMAT_FLAG);
mpv_observe_property(g_state.mpv, 12, "dwidth", MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 13, "dheight", MPV_FORMAT_INT64);
g_state.pipeline.onGenerationChanged = [](const std::string& name,
int width, int height,
uint32_t generation) {
emitLine(JsonWriter()
.str("event", "shm")
.str("name", name)
.num("width", width)
.num("height", height)
.num("generation", generation)
.finish());
};
g_state.viewportWidth = args.width;
g_state.viewportHeight = args.height;
std::string startError;
if (!g_state.pipeline.start(g_state.mpv, args.shmBase, args.width,
args.height, startError)) {
emitLine(JsonWriter()
.str("event", "fatal")
.str("error", startError)
.finish());
return 1;
}
std::thread renderThread([] { g_state.pipeline.runLoop(); });
/* The render context is created inside the render thread; hook the
* update callback once it exists (or bail if GL init failed). */
while (g_state.pipeline.initState() == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
if (g_state.pipeline.initState() < 0) {
renderThread.join();
mpv_terminate_destroy(g_state.mpv);
return 1;
}
mpv_render_context_set_update_callback(g_state.pipeline.renderContext(),
onRenderUpdate, &g_state.pipeline);
emitLine(JsonWriter()
.str("event", "hello")
.num("pid", (double)getpid())
.num("protocolVersion", 1)
.finish());
std::thread stdinThread(runStdinLoop);
runMpvEventLoop();
g_state.pipeline.stop();
renderThread.join();
mpv_terminate_destroy(g_state.mpv);
if (stdinThread.joinable()) {
/* stdin thread exits on EOF; detach if the pipe is still open. */
stdinThread.detach();
}
emitLine(JsonWriter().str("event", "bye").finish());
return 0;
}
@@ -0,0 +1,220 @@
/*
* embedded_mpv_frame_reader — N-API reader for the frame-copy shm ring.
*
* Loaded by the Electron preload script; copies the newest complete BGRA
* frame from the helper's shared-memory ring (native/helper/frame_shm.h)
* into a caller-provided ArrayBuffer. A memcpy is mandatory: Electron's V8
* 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.
*/
#define NAPI_VERSION 8
#include <node_api.h>
#ifdef __APPLE__
#include <fcntl.h>
#include <stdatomic.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include "../helper/frame_shm.h"
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);
}
static void unmap_current(void) {
if (g_base) {
munmap(g_base, g_size);
}
g_base = NULL;
g_header = NULL;
g_size = 0;
}
static napi_value throw_error(napi_env env, const char* message) {
napi_throw_error(env, NULL, message);
return NULL;
}
static void set_named_double(napi_env env, napi_value target, const char* key,
double value) {
napi_value wrapped;
napi_create_double(env, value, &wrapped);
napi_set_named_property(env, target, key, wrapped);
}
/* open(name) -> { width, height, stride, frameBytes, generation } */
static napi_value Open(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
if (argc < 1) return throw_error(env, "open(name) requires the shm name");
char name[256];
size_t written = 0;
if (napi_get_value_string_utf8(env, argv[0], name, sizeof(name),
&written) != napi_ok) {
return throw_error(env, "shm name must be a string");
}
const int fd = shm_open(name, O_RDONLY, 0);
if (fd < 0) return throw_error(env, "shm_open failed (helper gone?)");
struct stat segment;
if (fstat(fd, &segment) != 0 ||
segment.st_size < (off_t)sizeof(FrameShmHeader)) {
close(fd);
return throw_error(env, "shm segment too small");
}
void* mapped =
mmap(NULL, (size_t)segment.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (mapped == MAP_FAILED) return throw_error(env, "mmap failed");
FrameShmHeader* header = (FrameShmHeader*)mapped;
if (header->magic != FRAME_SHM_MAGIC ||
header->version != FRAME_SHM_VERSION) {
munmap(mapped, (size_t)segment.st_size);
return throw_error(env, "frame ring not initialized yet");
}
unmap_current();
g_base = (uint8_t*)mapped;
g_size = (size_t)segment.st_size;
g_header = header;
napi_value result;
napi_create_object(env, &result);
set_named_double(env, result, "width", header->width);
set_named_double(env, result, "height", header->height);
set_named_double(env, result, "stride", header->stride);
set_named_double(env, result, "frameBytes", (double)header->frame_bytes);
set_named_double(env, result, "generation", header->generation);
return result;
}
/* latestSeq() -> number (0 = no frame yet / not attached) */
static napi_value LatestSeq(napi_env env, napi_callback_info info) {
napi_value result;
const uint64_t seq =
g_header
? atomic_load_explicit(&g_header->latest_seq, memory_order_acquire)
: 0;
napi_create_double(env, (double)seq, &result);
return result;
}
/* copyLatest(arrayBuffer) -> { seq, ageMs, torn } | null */
static napi_value CopyLatest(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
if (!g_header) return throw_error(env, "call open() first");
if (argc < 1) return throw_error(env, "copyLatest(buffer) needs a buffer");
void* destination = NULL;
size_t destination_size = 0;
if (napi_get_arraybuffer_info(env, argv[0], &destination,
&destination_size) != napi_ok) {
return throw_error(env, "argument must be an ArrayBuffer");
}
if (destination_size < g_header->frame_bytes) {
return throw_error(env, "buffer smaller than one frame");
}
napi_value null_value;
napi_get_null(env, &null_value);
const uint64_t seq =
atomic_load_explicit(&g_header->latest_seq, memory_order_acquire);
if (seq == 0) return null_value;
FrameShmSlot* slot = &g_header->slots[seq % FRAME_SHM_RING_SLOTS];
if (atomic_load_explicit(&slot->seq, memory_order_acquire) != seq) {
return null_value; /* writer is racing this slot; next tick wins */
}
const uint8_t* source = g_base + g_header->data_offset +
(seq % FRAME_SHM_RING_SLOTS) *
g_header->frame_bytes;
memcpy(destination, source, (size_t)g_header->frame_bytes);
const uint64_t copied_at = now_ns();
const int torn =
atomic_load_explicit(&slot->seq, memory_order_acquire) != seq;
napi_value result;
napi_create_object(env, &result);
set_named_double(env, result, "seq", (double)seq);
set_named_double(env, result, "ageMs",
(double)(copied_at - slot->produce_time_ns) / 1e6);
napi_value torn_value;
napi_get_boolean(env, torn, &torn_value);
napi_set_named_property(env, result, "torn", torn_value);
return result;
}
/* producerAliveMs() -> ms since the helper's last heartbeat (-1 unknown) */
static napi_value ProducerAliveMs(napi_env env, napi_callback_info info) {
napi_value result;
double ms = -1;
if (g_header) {
const uint64_t heartbeat =
atomic_load_explicit(&g_header->heartbeat_ns, memory_order_relaxed);
if (heartbeat) ms = (double)(now_ns() - heartbeat) / 1e6;
}
napi_create_double(env, ms, &result);
return result;
}
/* close() -> undefined */
static napi_value Close(napi_env env, napi_callback_info info) {
unmap_current();
napi_value undefined;
napi_get_undefined(env, &undefined);
return undefined;
}
static napi_value Init(napi_env env, napi_value exports) {
const struct {
const char* name;
napi_callback fn;
} exported[] = {
{"open", Open},
{"latestSeq", LatestSeq},
{"copyLatest", CopyLatest},
{"producerAliveMs", ProducerAliveMs},
{"close", Close},
};
for (size_t i = 0; i < sizeof(exported) / sizeof(exported[0]); i++) {
napi_value fn;
napi_create_function(env, exported[i].name, NAPI_AUTO_LENGTH,
exported[i].fn, NULL, &fn);
napi_set_named_property(env, exports, exported[i].name, fn);
}
return exports;
}
#else /* !__APPLE__ */
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
* unsupported. */
(void)env;
return exports;
}
#endif
NAPI_MODULE(embedded_mpv_frame_reader, Init)
@@ -0,0 +1,40 @@
jest.mock('electron', () => ({
ipcRenderer: {
invoke: jest.fn(),
on: jest.fn(),
},
}));
import { copyStableLatestFrame } from './embedded-mpv-frame-pump';
describe('embedded MPV frame pump', () => {
const frame = new ArrayBuffer(16);
it('drops a torn copy without consuming its sequence so the next tick retries', () => {
const reader = {
latestSeq: jest.fn(() => 7),
copyLatest: jest
.fn()
.mockReturnValueOnce({ seq: 7, ageMs: 1, torn: true })
.mockReturnValueOnce({ seq: 7, ageMs: 1, torn: false }),
};
expect(copyStableLatestFrame(reader, frame, 6)).toBeNull();
expect(copyStableLatestFrame(reader, frame, 6)).toEqual({
seq: 7,
ageMs: 1,
torn: false,
});
expect(reader.copyLatest).toHaveBeenCalledTimes(2);
});
it('does not copy when the producer has no newer sequence', () => {
const reader = {
latestSeq: jest.fn(() => 6),
copyLatest: jest.fn(),
};
expect(copyStableLatestFrame(reader, frame, 6)).toBeNull();
expect(reader.copyLatest).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,317 @@
/**
* Frame pump for the frame-copy embedded MPV engine.
*
* Runs inside the preload script (isolated world, full Node access — the
* frame-copy experiment disables the window sandbox, see
* getMainWindowWebPreferences). It loads the shm frame-reader addon, copies
* the newest BGRA frame into a reused ArrayBuffer once per rAF, and uploads
* it into a WebGL2 texture on the renderer's `<canvas
* data-embedded-mpv-frame>` element. No frame data ever crosses the
* contextBridge — the bridge only exposes attach/detach lifecycle calls.
*/
import { ipcRenderer } from 'electron';
import {
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
EMBEDDED_MPV_GET_FRAME_SOURCE,
EmbeddedMpvFrameSource,
} from '@iptvnator/shared/interfaces';
interface FrameReaderInfo {
width: number;
height: number;
stride: number;
frameBytes: number;
generation: number;
}
interface FrameCopyResult {
seq: number;
ageMs: number;
torn: boolean;
}
interface FrameReaderAddon {
open(shmName: string): FrameReaderInfo;
latestSeq(): number;
copyLatest(buffer: ArrayBuffer): FrameCopyResult | null;
producerAliveMs(): number;
close(): void;
}
/* Webpack rewrites bare `require`; the addon must go through the runtime
* require so the .node file is loaded by Node itself. */
declare const __non_webpack_require__: NodeRequire | undefined;
const nodeRequire: NodeRequire =
typeof __non_webpack_require__ === 'function'
? __non_webpack_require__
: require;
const CANVAS_SELECTOR = 'canvas[data-embedded-mpv-frame]';
const ATTACH_TIMEOUT_MS = 5000;
const ATTACH_POLL_MS = 100;
// Note on orientation: the helper renders with MPV_RENDER_PARAM_FLIP_Y and
// glReadPixels reads rows bottom-up, so the shm buffer arrives in texture
// order already — sampling uses the un-flipped uv (a second flip here would
// show the video upside down).
const VERTEX_SHADER = `#version 300 es
out vec2 v_uv;
void main() {
vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
v_uv = corner;
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAGMENT_SHADER = `#version 300 es
precision mediump float;
uniform sampler2D u_tex;
in vec2 v_uv;
out vec4 o_color;
void main() {
vec4 c = texture(u_tex, v_uv);
o_color = vec4(c.b, c.g, c.r, 1.0);
}`;
interface PumpState {
sessionId: string;
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
texture: WebGLTexture;
reader: FrameReaderAddon;
frame: Uint8Array;
width: number;
height: number;
lastSeq: number;
rafHandle: number;
}
let pump: PumpState | null = null;
let sourceListenerRegistered = false;
/**
* Monotonic attach epoch: bumped by every attach/detach. Async attach waits
* re-check it after each await so a stale attach (for a session that was
* replaced meanwhile) can never install itself over the active pump.
*/
let attachEpoch = 0;
export function copyStableLatestFrame(
reader: Pick<FrameReaderAddon, 'latestSeq' | 'copyLatest'>,
buffer: ArrayBuffer,
lastSeq: number
): FrameCopyResult | null {
if (reader.latestSeq() <= lastSeq) return null;
const result = reader.copyLatest(buffer);
// A torn memcpy raced the helper overwriting this ring slot. Keep the
// previous sequence so the next animation tick retries instead of
// uploading partially copied pixels or marking the frame consumed.
return result && !result.torn ? result : null;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCanvas(): Promise<HTMLCanvasElement | null> {
const deadline = Date.now() + ATTACH_TIMEOUT_MS;
while (Date.now() < deadline) {
const canvas = document.querySelector<HTMLCanvasElement>(
CANVAS_SELECTOR
);
if (canvas) return canvas;
await delay(ATTACH_POLL_MS);
}
return null;
}
async function waitForFrameSource(
sessionId: string
): Promise<EmbeddedMpvFrameSource | null> {
const deadline = Date.now() + ATTACH_TIMEOUT_MS;
while (Date.now() < deadline) {
const source = (await ipcRenderer.invoke(
EMBEDDED_MPV_GET_FRAME_SOURCE,
sessionId
)) as EmbeddedMpvFrameSource | null;
if (source?.shmName) return source;
await delay(ATTACH_POLL_MS);
}
return null;
}
function createGl(
canvas: HTMLCanvasElement
): { gl: WebGL2RenderingContext; texture: WebGLTexture } | null {
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
preserveDrawingBuffer: false,
});
if (!gl) return null;
const program = gl.createProgram();
for (const [type, source] of [
[gl.VERTEX_SHADER, VERTEX_SHADER],
[gl.FRAGMENT_SHADER, FRAGMENT_SHADER],
] as const) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(
'[embedded-mpv-pump] shader error:',
gl.getShaderInfoLog(shader)
);
return null;
}
gl.attachShader(program, shader);
}
gl.linkProgram(program);
gl.useProgram(program);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { gl, texture: texture as WebGLTexture };
}
function applySource(state: PumpState, source: EmbeddedMpvFrameSource): boolean {
try {
const info = state.reader.open(source.shmName);
state.width = info.width;
state.height = info.height;
state.frame = new Uint8Array(info.frameBytes);
state.lastSeq = 0;
state.canvas.width = info.width;
state.canvas.height = info.height;
state.gl.viewport(0, 0, info.width, info.height);
state.gl.texImage2D(
state.gl.TEXTURE_2D,
0,
state.gl.RGBA,
info.width,
info.height,
0,
state.gl.RGBA,
state.gl.UNSIGNED_BYTE,
null
);
return true;
} catch (error) {
console.error('[embedded-mpv-pump] failed to open frame ring:', error);
return false;
}
}
function pumpTick(): void {
if (!pump) return;
pump.rafHandle = requestAnimationFrame(pumpTick);
const { gl, reader, frame } = pump;
const result = copyStableLatestFrame(
reader,
frame.buffer as ArrayBuffer,
pump.lastSeq
);
if (result) {
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
pump.width,
pump.height,
gl.RGBA,
gl.UNSIGNED_BYTE,
frame
);
pump.lastSeq = result.seq;
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
function ensureSourceListener(): void {
if (sourceListenerRegistered) return;
sourceListenerRegistered = true;
ipcRenderer.on(
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
(_event, payload: { sessionId: string; source: EmbeddedMpvFrameSource }) => {
if (!pump || payload.sessionId !== pump.sessionId) return;
applySource(pump, payload.source);
}
);
}
export async function attachEmbeddedMpvFrameView(
sessionId: string
): Promise<boolean> {
detachEmbeddedMpvFrameView();
const epoch = ++attachEpoch;
ensureSourceListener();
const canvas = await waitForCanvas();
if (epoch !== attachEpoch) return false;
if (!canvas) {
console.error('[embedded-mpv-pump] frame canvas not found in DOM');
return false;
}
const source = await waitForFrameSource(sessionId);
if (epoch !== attachEpoch) return false;
if (!source) {
console.error('[embedded-mpv-pump] no frame source for', sessionId);
return false;
}
let reader: FrameReaderAddon;
try {
reader = nodeRequire(source.readerPath) as FrameReaderAddon;
} catch (error) {
console.error('[embedded-mpv-pump] failed to load reader:', error);
return false;
}
const glSetup = createGl(canvas);
if (!glSetup) {
console.error('[embedded-mpv-pump] WebGL2 unavailable');
return false;
}
const state: PumpState = {
sessionId,
canvas,
gl: glSetup.gl,
texture: glSetup.texture,
reader,
frame: new Uint8Array(0),
width: 0,
height: 0,
lastSeq: 0,
rafHandle: 0,
};
if (!applySource(state, source)) {
reader.close();
return false;
}
pump = state;
pump.rafHandle = requestAnimationFrame(pumpTick);
return true;
}
export function detachEmbeddedMpvFrameView(): void {
attachEpoch++; // aborts any attach still waiting on canvas/frame source
if (!pump) return;
cancelAnimationFrame(pump.rafHandle);
try {
pump.reader.close();
} catch {
// reader may already be gone with the helper; nothing to release
}
pump = null;
}
@@ -7,6 +7,10 @@ import {
APP_UPDATE_INSTALL,
APP_UPDATE_STATUS_CHANGED,
} from '@iptvnator/shared/interfaces/ipc-commands';
import {
attachEmbeddedMpvFrameView,
detachEmbeddedMpvFrameView,
} from './embedded-mpv-frame-pump';
import type {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -484,6 +488,9 @@ const electronApi: ElectronBridgeApi = {
sessionId: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId),
attachEmbeddedMpvFrameView: (sessionId: string): Promise<boolean> =>
attachEmbeddedMpvFrameView(sessionId),
detachEmbeddedMpvFrameView: (): void => detachEmbeddedMpvFrameView(),
autoUpdatePlaylists: (
playlists: Playlist[],
options?: ElectronBridgeTrustOptions
+35
View File
@@ -1,4 +1,6 @@
const mockClearStorageData = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockIsEmbeddedMpvFeatureEnabled = jest.fn<boolean, []>();
jest.mock('electron', () => ({
app: {
@@ -32,6 +34,14 @@ jest.mock('./services/store.service', () => ({
WINDOW_BOUNDS: 'windowBounds',
}));
jest.mock('./services/embedded-mpv-frame-copy-platform.util', () => ({
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
}));
jest.mock('./services/embedded-mpv-runtime-policy.util', () => ({
isEmbeddedMpvFeatureEnabled: mockIsEmbeddedMpvFeatureEnabled,
}));
import {
clearElectronServiceWorkerStorage,
getMainWindowWebPreferences,
@@ -93,6 +103,10 @@ describe('Electron app security helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.ELECTRON_IS_DEV;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(false);
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
const appInternals = getAppInternals();
appInternals.loadedMainWindow = null;
appInternals.mainWindow = null;
@@ -117,6 +131,27 @@ describe('Electron app security helpers', () => {
);
});
it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
});
it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
});
it('relaxes the renderer sandbox only when embedded MPV and a usable frame-copy runtime are enabled', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(false);
});
it('treats only http and https URLs as external browser URLs', () => {
expect(isExternalBrowserUrl('https://example.com')).toBe(true);
expect(isExternalBrowserUrl('http://example.com')).toBe(true);
+15 -1
View File
@@ -10,6 +10,8 @@ import {
trace,
} from './services/debug-trace';
import { store, WINDOW_BOUNDS } from './services/store.service';
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
const externalBrowserProtocols = new Set(['http:', 'https:']);
const trustedDevRendererHosts = new Set([
@@ -79,10 +81,22 @@ export function isTrustedRendererNavigationUrl(
}
export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] {
// The frame-copy embedded MPV experiment needs the preload script to
// load the shm frame-reader native addon, which the renderer sandbox
// forbids. Only that opt-in flag relaxes the sandbox; context isolation
// and nodeIntegration:false stay on either way, so page code never
// gains Node access. Revisit before the engine can become a default.
const frameCopyExperiment =
isEmbeddedMpvFeatureEnabled() &&
['1', 'true', 'yes', 'on'].includes(
(process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '')
.trim()
.toLowerCase()
) && isFrameCopyRuntimeUsable();
return {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
sandbox: !frameCopyExperiment,
webSecurity: true,
backgroundThrottling: false,
preload: join(__dirname, 'main.preload.js'),
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_CREATE_SESSION,
EMBEDDED_MPV_DISPOSE_SESSION,
EMBEDDED_MPV_GET_FRAME_SOURCE,
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SEEK,
@@ -142,6 +143,10 @@ handleEmbeddedMpv(EMBEDDED_MPV_DISPOSE_SESSION, (sessionId: string) =>
getService().disposeSession(sessionId)
);
handleEmbeddedMpv(EMBEDDED_MPV_GET_FRAME_SOURCE, (sessionId: string) =>
getService().getFrameSource(sessionId)
);
export function shutdownEmbeddedMpv(): void {
getService().shutdown();
}
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron';
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
import {
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
store,
@@ -37,6 +38,11 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
}
// Applied on the next app start (window sandbox is fixed at creation).
if (arg.embeddedMpvFrameCopy !== undefined) {
store.set(EMBEDDED_MPV_FRAME_COPY, !!arg.embeddedMpvFrameCopy);
}
if (arg.vlcReuseInstance !== undefined) {
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
}
@@ -0,0 +1,167 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
const mockElectronApp = {
isPackaged: false,
getAppPath: jest.fn(() => ''),
};
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
isFrameCopyPlatformSupported,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util';
describe('embedded-mpv-frame-copy-platform.util', () => {
describe('isFrameCopyPlatformSupported', () => {
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', false],
['win32', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
Object.defineProperty(process, 'platform', { value: platform });
Object.defineProperty(process, 'arch', { value: arch });
expect(isFrameCopyPlatformSupported()).toBe(expected);
});
});
describe('resolveFrameCopyHelperPath', () => {
let tempDir: string;
let cwdSpy: jest.SpyInstance<string, []>;
let originalResourcesPath: string | undefined;
const releaseDir = () =>
path.join(
tempDir,
'apps',
'electron-backend',
'native',
'build',
'Release'
);
const helperPath = () =>
path.join(releaseDir(), 'iptvnator_mpv_helper');
const readerPath = () =>
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
beforeEach(() => {
tempDir = mkdtempSync(path.join(tmpdir(), 'impv-fc-util-'));
mkdirSync(releaseDir(), { recursive: true });
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir);
mockElectronApp.isPackaged = false;
mockElectronApp.getAppPath.mockReturnValue('');
originalResourcesPath = (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath;
});
afterEach(() => {
cwdSpy.mockRestore();
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath,
});
rmSync(tempDir, { recursive: true, force: true });
});
it('returns null when no helper exists', () => {
expect(resolveFrameCopyHelperPath()).toBeNull();
});
it('returns an executable helper next to the local-build addon path', () => {
writeFileSync(helperPath(), '#!/bin/sh\n');
chmodSync(helperPath(), 0o755);
writeFileSync(readerPath(), 'reader');
expect(resolveFrameCopyHelperPath()).toBe(helperPath());
});
it('ignores an executable helper without its frame reader addon', () => {
writeFileSync(helperPath(), '#!/bin/sh\n');
chmodSync(helperPath(), 0o755);
expect(resolveFrameCopyHelperPath()).toBeNull();
});
(process.platform === 'win32' ? it.skip : it)(
'ignores a helper without the execute bit',
() => {
writeFileSync(helperPath(), '#!/bin/sh\n');
chmodSync(helperPath(), 0o644);
writeFileSync(readerPath(), 'reader');
expect(resolveFrameCopyHelperPath()).toBeNull();
}
);
it('does not fall back to cwd build artifacts in a packaged app', () => {
writeFileSync(helperPath(), '#!/bin/sh\n');
chmodSync(helperPath(), 0o755);
writeFileSync(readerPath(), 'reader');
mockElectronApp.isPackaged = true;
const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources');
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
mockElectronApp.getAppPath.mockReturnValue(
path.join(resourcesPath, 'app.asar')
);
expect(resolveFrameCopyHelperPath()).toBeNull();
const packagedNativeDir = path.join(
resourcesPath,
'app.asar.unpacked',
'electron-backend',
'native'
);
mkdirSync(packagedNativeDir, { recursive: true });
const packagedHelper = path.join(
packagedNativeDir,
'iptvnator_mpv_helper'
);
writeFileSync(packagedHelper, '#!/bin/sh\n');
chmodSync(packagedHelper, 0o755);
writeFileSync(
path.join(packagedNativeDir, 'embedded_mpv_frame_reader.node'),
'reader'
);
expect(resolveFrameCopyHelperPath()).toBe(packagedHelper);
});
});
it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => {
const shouldPromote = (
frameCopyPlatform as typeof frameCopyPlatform & {
shouldPromotePersistedFrameCopyOptIn?: (
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable: boolean
) => boolean;
}
).shouldPromotePersistedFrameCopyOptIn;
expect(shouldPromote).toBeDefined();
expect(shouldPromote?.(true, undefined, true)).toBe(true);
expect(shouldPromote?.(true, undefined, false)).toBe(false);
expect(shouldPromote?.(true, '0', true)).toBe(false);
expect(shouldPromote?.(true, '1', true)).toBe(false);
expect(shouldPromote?.(false, undefined, true)).toBe(false);
});
});
@@ -0,0 +1,126 @@
import { app } from 'electron';
import { accessSync, constants as fsConstants, statSync } from 'fs';
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().
*/
export function isFrameCopyPlatformSupported(): boolean {
return process.platform === 'darwin' && process.arch === 'arm64';
}
function dedupeDefinedPaths(paths: Array<string | undefined>): string[] {
return [
...new Set(paths.filter((value): value is string => Boolean(value))),
];
}
function getLocalBuildAddonPath(): string {
return path.resolve(
process.cwd(),
'apps/electron-backend/native/build/Release/embedded_mpv.node'
);
}
function getDistAddonPaths(): string[] {
return [
path.resolve(__dirname, 'native/embedded_mpv.node'),
path.resolve(__dirname, '../../native/embedded_mpv.node'),
];
}
function getPackagedAddonPaths(): string[] {
const resourcesPath = (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath;
return dedupeDefinedPaths([
resourcesPath
? path.resolve(
resourcesPath,
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
)
: undefined,
app.getAppPath()
? path.join(
path.dirname(app.getAppPath()),
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
)
: undefined,
]);
}
/** Candidate locations of the embedded MPV addon, most likely first. */
export function getEmbeddedMpvAddonCandidatePaths(): string[] {
const localBuildAddonPath = getLocalBuildAddonPath();
const distAddonPaths = getDistAddonPaths();
const packagedAddonPaths = getPackagedAddonPaths();
return dedupeDefinedPaths(
app.isPackaged
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
);
}
/**
* Resolve the first executable frame-copy helper with a readable regular-file
* frame reader beside it. Both artifacts are required before the engine may
* relax the renderer sandbox.
*/
export function resolveFrameCopyHelperPath(): string | null {
// Packaged startup must never fall through to cwd/dist development
// artifacts: doing so would let a writable launch directory supply the
// executable that causes BrowserWindow sandbox relaxation.
const addonCandidates = app.isPackaged
? getPackagedAddonPaths()
: [getLocalBuildAddonPath(), ...getDistAddonPaths()];
return (
addonCandidates
.map((candidatePath) => path.dirname(candidatePath))
.map((nativeDir) => ({
helper: path.join(nativeDir, 'iptvnator_mpv_helper'),
reader: path.join(
nativeDir,
'embedded_mpv_frame_reader.node'
),
}))
.find(({ helper, reader }) => {
try {
accessSync(helper, fsConstants.X_OK);
accessSync(reader, fsConstants.R_OK);
if (
!statSync(helper).isFile() ||
!statSync(reader).isFile()
) {
return false;
}
return true;
} catch {
return false;
}
})?.helper ?? null
);
}
export function isFrameCopyRuntimeUsable(
resolveHelper: () => string | null = resolveFrameCopyHelperPath
): boolean {
return isFrameCopyPlatformSupported() && resolveHelper() !== null;
}
export function shouldPromotePersistedFrameCopyOptIn(
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable = isFrameCopyRuntimeUsable()
): boolean {
return explicitEnv === undefined && storedEnabled && runtimeUsable;
}
@@ -0,0 +1,176 @@
import { EventEmitter } from 'events';
const spawnMock = jest.fn();
jest.mock('child_process', () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}));
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
class FakeHelperProcess extends EventEmitter {
exitCode: number | null = null;
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly stdin = {
writable: true,
written: [] as string[],
write(line: string) {
this.written.push(line);
return true;
},
};
readonly kill = jest.fn((signal?: string) => {
this.exitCode = 0;
this.emit('exit', 0, signal ?? null);
return true;
});
emitStdout(payload: object): void {
this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`));
}
}
describe('EmbeddedMpvFrameCopyAdapter', () => {
let child: FakeHelperProcess;
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>;
let adapter: EmbeddedMpvFrameCopyAdapter;
const createAdapter = (helperPath: string | null = '/native/helper') => {
frameSourceChanges = [];
return new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => helperPath,
getScaleFactor: () => 2,
onFrameSourceChanged: (sessionId, source) =>
frameSourceChanges.push({ sessionId, shmName: source.shmName }),
});
};
beforeEach(() => {
jest.useFakeTimers();
child = new FakeHelperProcess();
spawnMock.mockReset();
spawnMock.mockReturnValue(child);
adapter = createAdapter();
});
afterEach(() => {
jest.useRealTimers();
});
const createSession = () =>
adapter.createSession(
Buffer.alloc(0),
{ x: 0, y: 0, width: 640, height: 360 },
'Title',
0.8
);
it('spawns the helper with device-pixel size and initial volume', () => {
const sessionId = createSession();
expect(sessionId).toMatch(/^impv-fc-/);
const [helperPath, args] = spawnMock.mock.calls[0];
expect(helperPath).toBe('/native/helper');
expect(args).toEqual([
'--shm-base',
`/${sessionId}`,
'--width',
'1280',
'--height',
'720',
'--volume',
'0.8',
]);
});
it('caches helper snapshot events for getSessionSnapshot', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'playing',
positionSeconds: 12.5,
durationSeconds: 60,
volume: 0.8,
streamUrl: 'http://stream',
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
});
const snapshot = adapter.getSessionSnapshot(sessionId);
expect(snapshot?.status).toBe('playing');
expect(snapshot?.positionSeconds).toBe(12.5);
expect(snapshot?.streamUrl).toBe('http://stream');
});
it('publishes shm generations through onFrameSourceChanged', () => {
const sessionId = createSession();
child.emitStdout({
event: 'shm',
name: `/${sessionId}-g1`,
width: 1280,
height: 720,
generation: 1,
});
expect(frameSourceChanges).toEqual([
{ sessionId, shmName: `/${sessionId}-g1` },
]);
expect(adapter.getFrameSource(sessionId)?.readerPath).toBe(
'/native/embedded_mpv_frame_reader.node'
);
});
it('encodes loadfile options with percent-escaping', () => {
const sessionId = createSession();
adapter.loadPlayback(sessionId, {
streamUrl: 'http://host/live.m3u8',
title: 'Tab\there',
userAgent: 'UA 1.0',
startTime: 42,
headers: { 'X-Token': 'abc' },
});
const line = child.stdin.written.at(-1) ?? '';
expect(line.startsWith('load\turl=http://host/live.m3u8\t')).toBe(true);
expect(line).toContain('opt.force-media-title=Tab%09here');
expect(line).toContain('opt.user-agent=UA 1.0');
expect(line).toContain('opt.start=42');
expect(line).toContain('opt.http-header-fields=X-Token: abc');
});
it('scales bounds and ignores hidden/degenerate bounds', () => {
const sessionId = createSession();
adapter.setBounds(sessionId, { x: 0, y: 0, width: 800, height: 450 });
expect(child.stdin.written.at(-1)).toBe(
'size\twidth=1600\theight=900\n'
);
const writesBefore = child.stdin.written.length;
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
expect(child.stdin.written.length).toBe(writesBefore);
});
it('maps an unexpected helper exit to a session error', () => {
const sessionId = createSession();
child.exitCode = 1;
child.emit('exit', 1, null);
const snapshot = adapter.getSessionSnapshot(sessionId);
expect(snapshot?.status).toBe('error');
expect(snapshot?.error).toContain('exited unexpectedly');
});
it('disposes with quit and escalates to SIGTERM', () => {
const sessionId = createSession();
adapter.disposeSession(sessionId);
expect(child.stdin.written.at(-1)).toBe('quit\n');
expect(adapter.getSessionSnapshot(sessionId)).toBeNull();
child.exitCode = null; // helper ignored quit
jest.advanceTimersByTime(600);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
});
it('reports unsupported without a helper binary', () => {
const withoutHelper = createAdapter(null);
expect(withoutHelper.isSupported()).toBe(false);
});
});
@@ -0,0 +1,388 @@
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { randomUUID } from 'crypto';
import path from 'path';
import {
EmbeddedMpvBounds,
EmbeddedMpvFrameSource,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type {
NativeEmbeddedMpvAddon,
NativeEmbeddedMpvSessionSnapshot,
} from './embedded-mpv-native.service';
/**
* Frame-copy engine adapter: implements the same surface as the native
* embedded MPV addon, but backed by a per-session `iptvnator_mpv_helper`
* process. The helper owns libmpv (decode, offscreen render at viewport
* size, audio) and publishes BGRA frames into a shared-memory ring that the
* preload frame pump uploads to a renderer canvas.
*
* Protocol: tab-separated commands over stdin, JSON events over stdout.
* The helper's `snapshot` events already carry the
* NativeEmbeddedMpvSessionSnapshot shape, so this adapter is mostly a
* process-lifecycle wrapper plus a snapshot cache that the existing
* EmbeddedMpvNativeService polling consumes unchanged.
*/
export interface EmbeddedMpvFrameCopyAdapterOptions {
resolveHelperPath: () => string | null;
getScaleFactor: () => number;
onFrameSourceChanged: (
sessionId: string,
source: EmbeddedMpvFrameSource
) => void;
}
interface FrameCopyRuntimeSession {
id: string;
child: ChildProcessWithoutNullStreams;
snapshot: NativeEmbeddedMpvSessionSnapshot;
frameSource: EmbeddedMpvFrameSource | null;
stdoutBuffer: string;
disposed: boolean;
killTimers: NodeJS.Timeout[];
}
const HELPER_QUIT_GRACE_MS = 500;
const HELPER_KILL_GRACE_MS = 2000;
function encodeProtocolValue(value: string): string {
return value
.replace(/%/g, '%25')
.replace(/\t/g, '%09')
.replace(/\n/g, '%0A')
.replace(/\r/g, '%0D');
}
function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
return {
status: 'loading',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
streamUrl: '',
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
};
}
export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
private readonly sessions = new Map<string, FrameCopyRuntimeSession>();
constructor(
private readonly options: EmbeddedMpvFrameCopyAdapterOptions
) {}
isSupported(): boolean {
return (
process.platform === 'darwin' &&
process.arch === 'arm64' &&
this.options.resolveHelperPath() !== null
);
}
createSession(
_windowHandle: Buffer,
bounds: EmbeddedMpvBounds,
_title?: string,
initialVolume?: number
): string {
const helperPath = this.options.resolveHelperPath();
if (!helperPath) {
throw new Error(
'The frame-copy embedded MPV helper binary was not found.'
);
}
const sessionId = `impv-fc-${randomUUID().slice(0, 8)}`;
const scale = this.options.getScaleFactor();
const width = Math.max(16, Math.round(bounds.width * scale));
const height = Math.max(16, Math.round(bounds.height * scale));
const child = spawn(
helperPath,
[
'--shm-base',
`/${sessionId}`,
'--width',
String(width),
'--height',
String(height),
'--volume',
String(Math.min(Math.max(initialVolume ?? 1, 0), 1)),
// Lip-sync compensation for the video path's added latency
// (~10 ms measured on M1 Pro); tunable until calibration
// lands, see the architecture doc.
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
? [
'--audio-delay',
process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY,
]
: []),
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
console.log(
`[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)`
);
const session: FrameCopyRuntimeSession = {
id: sessionId,
child,
snapshot: createInitialSnapshot(),
frameSource: null,
stdoutBuffer: '',
disposed: false,
killTimers: [],
};
this.sessions.set(sessionId, session);
child.stdout.on('data', (chunk: Buffer) =>
this.consumeStdout(session, chunk)
);
child.stderr.on('data', (chunk: Buffer) => {
console.error(
`[embedded-mpv-fc][${sessionId}] ${chunk.toString().trim()}`
);
});
child.on('error', (error) => {
session.snapshot.status = 'error';
session.snapshot.error = `Helper process failed: ${error.message}`;
});
child.on('exit', (code, signal) => {
console.log(
`[embedded-mpv-fc][${sessionId}] exit code=${code} signal=${signal} disposed=${session.disposed}`
);
session.killTimers.forEach((timer) => clearTimeout(timer));
session.killTimers = [];
if (session.disposed) {
session.snapshot.status = 'closed';
return;
}
// An unexpected helper death must surface as a session error so
// the renderer can fall back — never crash the main process.
session.snapshot.status = 'error';
session.snapshot.error = `The embedded MPV helper exited unexpectedly (${
signal ?? code ?? 'unknown'
}).`;
});
return sessionId;
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`];
if (playback.title) {
fields.push(
`opt.force-media-title=${encodeProtocolValue(playback.title)}`
);
}
if (playback.userAgent) {
fields.push(
`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`
);
}
if (playback.referer) {
fields.push(
`opt.referrer=${encodeProtocolValue(playback.referer)}`
);
}
if (
typeof playback.startTime === 'number' &&
Number.isFinite(playback.startTime) &&
playback.startTime >= 0
) {
fields.push(`opt.start=${playback.startTime}`);
}
if (playback.headers && Object.keys(playback.headers).length > 0) {
const headerFields = Object.entries(playback.headers)
.map(([key, value]) => `${key}: ${value}`)
.join(',');
fields.push(
`opt.http-header-fields=${encodeProtocolValue(headerFields)}`
);
}
this.send(sessionId, `load\t${fields.join('\t')}`);
}
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
const scale = this.options.getScaleFactor();
const width = Math.round(bounds.width * scale);
const height = Math.round(bounds.height * scale);
if (width < 16 || height < 16) {
// Off-screen/hidden bounds are meaningless for a DOM canvas
// engine; the video keeps rendering at its last real size.
return;
}
this.send(sessionId, `size\twidth=${width}\theight=${height}`);
}
setPaused(sessionId: string, paused: boolean): void {
this.send(sessionId, `pause\tvalue=${paused ? 1 : 0}`);
}
seek(sessionId: string, seconds: number): void {
this.send(sessionId, `seek\tseconds=${seconds}`);
}
setVolume(sessionId: string, volume: number): void {
this.send(sessionId, `volume\tvalue=${volume}`);
}
setAudioTrack(sessionId: string, trackId: number): void {
this.send(sessionId, `aid\tvalue=${trackId}`);
}
setSubtitleTrack(sessionId: string, trackId: number): void {
this.send(sessionId, `sid\tvalue=${trackId}`);
}
setSpeed(sessionId: string, speed: number): void {
this.send(sessionId, `speed\tvalue=${speed}`);
}
setAspect(sessionId: string, aspect: string): void {
this.send(sessionId, `aspect\tvalue=${encodeProtocolValue(aspect)}`);
}
startRecording(sessionId: string, targetPath: string): void {
this.send(sessionId, `record\tpath=${encodeProtocolValue(targetPath)}`);
}
stopRecording(sessionId: string): void {
this.send(sessionId, 'record\tpath=');
}
getSessionSnapshot(
sessionId: string
): NativeEmbeddedMpvSessionSnapshot | null {
const session = this.sessions.get(sessionId);
return session ? { ...session.snapshot } : null;
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
return this.sessions.get(sessionId)?.frameSource ?? null;
}
disposeSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.disposed = true;
console.log(`[embedded-mpv-fc][${sessionId}] dispose`);
this.send(sessionId, 'quit');
const child = session.child;
// Belt and braces: the helper also exits on stdin EOF, so closing
// the pipe covers a helper that missed the quit line.
try {
child.stdin.end();
} catch {
// stdin may already be destroyed with the process
}
session.killTimers.push(
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGTERM');
}, HELPER_QUIT_GRACE_MS),
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL');
}, HELPER_KILL_GRACE_MS)
);
this.sessions.delete(sessionId);
}
private send(sessionId: string, line: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(
`Embedded MPV frame-copy session "${sessionId}" was not found.`
);
}
if (session.child.exitCode !== null || !session.child.stdin.writable) {
return;
}
session.child.stdin.write(`${line}\n`);
}
private consumeStdout(
session: FrameCopyRuntimeSession,
chunk: Buffer
): void {
session.stdoutBuffer += chunk.toString('utf8');
let newlineIndex = session.stdoutBuffer.indexOf('\n');
while (newlineIndex >= 0) {
const line = session.stdoutBuffer.slice(0, newlineIndex).trim();
session.stdoutBuffer = session.stdoutBuffer.slice(newlineIndex + 1);
newlineIndex = session.stdoutBuffer.indexOf('\n');
if (!line) continue;
try {
this.handleEvent(session, JSON.parse(line));
} catch {
console.error(
`[embedded-mpv-fc][${session.id}] unparseable event: ${line}`
);
}
}
}
private handleEvent(
session: FrameCopyRuntimeSession,
event: Record<string, unknown>
): void {
switch (event.event) {
case 'snapshot': {
const { event: _ignored, ...snapshot } = event;
session.snapshot = {
...session.snapshot,
...(snapshot as Partial<NativeEmbeddedMpvSessionSnapshot>),
} as NativeEmbeddedMpvSessionSnapshot;
break;
}
case 'shm': {
const source: EmbeddedMpvFrameSource = {
shmName: String(event.name ?? ''),
width: Number(event.width ?? 0),
height: Number(event.height ?? 0),
generation: Number(event.generation ?? 0),
readerPath: this.resolveReaderPath(),
};
session.frameSource = source;
this.options.onFrameSourceChanged(session.id, source);
break;
}
case 'fatal':
session.snapshot.status = 'error';
session.snapshot.error = String(
event.error ?? 'Embedded MPV helper failed.'
);
break;
case 'log':
if (event.level === 'error' || event.level === 'fatal') {
console.error(
`[embedded-mpv-fc][${session.id}][mpv/${String(
event.prefix ?? ''
)}] ${String(event.text ?? '').trim()}`
);
}
break;
default:
break;
}
}
private resolveReaderPath(): string {
const helperPath = this.options.resolveHelperPath();
return helperPath
? path.join(
path.dirname(helperPath),
'embedded_mpv_frame_reader.node'
)
: '';
}
}
@@ -39,6 +39,13 @@ describe('Embedded MPV native source recording invariants', () => {
),
'utf8'
);
const frameHelperRenderSource = readFileSync(
path.resolve(
__dirname,
'../../../native/helper/frame_helper_render.h'
),
'utf8'
);
function functionBody(name: string): string {
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
@@ -599,6 +606,20 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
expect(buildScriptSource).toContain('runtimeFiles: []');
});
it('forces the current frame into a rebuilt shm generation after a paused resize', () => {
const runLoop = sourceFunctionBody(
frameHelperRenderSource,
'inline void RenderPipeline::runLoop()',
'RenderPipeline::runLoop'
);
expect(runLoop).toContain('bool targetsRebuilt = false;');
expect(runLoop).toContain('targetsRebuilt = true;');
expect(runLoop).toContain(
'if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME))'
);
});
});
describe('Embedded MPV native build configuration', () => {
@@ -3,7 +3,14 @@ import type {
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
@@ -34,13 +41,14 @@ jest.mock('electron', () => ({
}));
const mainWindowSendMock = jest.fn();
const mainWindowWebContentsOnMock = jest.fn();
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
Buffer.alloc(8)
);
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
webContents: { send: mainWindowSendMock },
webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock },
};
jest.mock('../app', () => ({
@@ -112,7 +120,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
let addon: MockAddon;
let nextBlockerId: number;
let originalPlatform: NodeJS.Platform;
let originalArch: string;
let originalDisplay: string | undefined;
let originalExperiment: string | undefined;
let originalOzonePlatformHint: string | undefined;
let originalWaylandDisplay: string | undefined;
let tempDirs: string[];
@@ -131,16 +141,22 @@ describe('EmbeddedMpvNativeService power blocker', () => {
mainWindowGetNativeWindowHandleMock.mockReset();
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
mainWindowWebContentsOnMock.mockReset();
appMock.isPackaged = true;
tempDirs = [];
nextBlockerId = 1;
powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++);
powerSaveBlockerMock.isStarted.mockReturnValue(true);
originalPlatform = process.platform;
originalArch = process.arch;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
Object.defineProperty(process, 'arch', { value: 'arm64' });
originalDisplay = process.env.DISPLAY;
originalExperiment =
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
originalOzonePlatformHint = process.env.ELECTRON_OZONE_PLATFORM_HINT;
originalWaylandDisplay = process.env.WAYLAND_DISPLAY;
@@ -162,7 +178,12 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
restoreEnv('DISPLAY', originalDisplay);
restoreEnv(
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT',
originalExperiment
);
restoreEnv('ELECTRON_OZONE_PLATFORM_HINT', originalOzonePlatformHint);
restoreEnv('WAYLAND_DISPLAY', originalWaylandDisplay);
});
@@ -204,11 +225,117 @@ describe('EmbeddedMpvNativeService power blocker', () => {
};
}
it('falls back to the native engine when frame-copy is requested without a helper binary', () => {
// A stale opt-in (cleaned native build) must not brick embedded MPV:
// no helper on disk => the engine env flag is ignored, native keeps
// working, and support does not advertise frame-copy.
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue(null);
try {
expect(service.getActiveEngine()).toBe('native');
const support = service.getSupport();
expect(support.engine).not.toBe('frame-copy');
startSession('s-fallback', snapshot('loading'));
expect(addon.createSession).toHaveBeenCalled();
} finally {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
}
});
it('requires the base embedded-MPV opt-in for unpackaged runs', () => {
appMock.isPackaged = false;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
expect(service.getSupport()).toEqual(
expect.objectContaining({ supported: false })
);
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = '1';
expect(service.getSupport()).toEqual(
expect.objectContaining({ supported: true })
);
});
(process.platform === 'win32' ? it.skip : it)(
'falls back to the native engine when the frame-copy helper is not executable',
() => {
const tempDir = createTempDir();
const releaseDir = path.join(
tempDir,
'apps',
'electron-backend',
'native',
'build',
'Release'
);
const helperPath = path.join(
releaseDir,
'iptvnator_mpv_helper'
);
mkdirSync(releaseDir, { recursive: true });
writeFileSync(helperPath, '#!/bin/sh\n');
chmodSync(helperPath, 0o644);
writeFileSync(
path.join(releaseDir, 'embedded_mpv_frame_reader.node'),
'reader'
);
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir);
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
try {
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
} finally {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
cwdSpy.mockRestore();
}
}
);
it('does not acquire a blocker for a loading session', () => {
startSession('s1', snapshot('loading'));
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
});
it('disposes sessions when the renderer reloads or crashes', () => {
// Angular teardown never runs on a renderer crash or hard reload, so
// the main process must reap sessions itself — otherwise native mpv
// handles / frame-copy helper processes leak until app shutdown.
startSession('s1', snapshot('playing'));
addon.getSessionSnapshot.mockReturnValue(snapshot('playing'));
const handlers = new Map<string, (...args: unknown[]) => void>(
mainWindowWebContentsOnMock.mock.calls.map(
([event, handler]: [string, (...args: unknown[]) => void]) => [
event,
handler,
]
)
);
expect([...handlers.keys()]).toEqual(
expect.arrayContaining(['render-process-gone', 'did-navigate'])
);
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation();
handlers.get('did-navigate')?.();
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
// A crash after everything is already disposed must be a no-op.
addon.disposeSession.mockClear();
handlers.get('render-process-gone')?.(undefined, {
reason: 'crashed',
});
expect(addon.disposeSession).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it('keeps the polling timer alive when refreshing a session throws', () => {
jest.useFakeTimers();
const consoleErrorSpy = jest
@@ -1,4 +1,4 @@
import { app, dialog, powerSaveBlocker } from 'electron';
import { app, dialog, powerSaveBlocker, screen } from 'electron';
import { spawnSync } from 'child_process';
import {
closeSync,
@@ -20,12 +20,25 @@ import {
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvEngine,
EmbeddedMpvFrameSource,
EmbeddedMpvSupport,
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getEmbeddedMpvAddonCandidatePaths,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import {
EMBEDDED_MPV_EXPERIMENT_ENV,
isEmbeddedMpvFeatureEnabled,
} from './embedded-mpv-runtime-policy.util';
interface NativeEmbeddedMpvSessionSnapshot {
export interface NativeEmbeddedMpvSessionSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
@@ -37,11 +50,13 @@ interface NativeEmbeddedMpvSessionSnapshot {
selectedSubtitleTrackId?: number | null;
playbackSpeed?: number;
aspectOverride?: string;
videoWidth?: number;
videoHeight?: number;
recording?: EmbeddedMpvRecordingState;
error?: string;
}
interface NativeEmbeddedMpvAddon {
export interface NativeEmbeddedMpvAddon {
isSupported(): boolean;
createSession(
windowHandle: Buffer,
@@ -76,19 +91,13 @@ interface EmbeddedMpvRuntimeSession {
lastStatus: EmbeddedMpvSessionStatus | null;
}
const EMBEDDED_MPV_EXPERIMENT_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set<NodeJS.Platform>([
'darwin',
'win32',
'linux',
]);
function dedupePaths(paths: Array<string | undefined>): string[] {
return [
...new Set(paths.filter((value): value is string => Boolean(value))),
];
}
export class EmbeddedMpvNativeService {
private addon: NativeEmbeddedMpvAddon | null = null;
private addonLoadError: Error | null = null;
@@ -98,8 +107,93 @@ export class EmbeddedMpvNativeService {
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private cachedLinuxMpvExecutableReason: string | null | undefined;
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
/**
* 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.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(process.env[EMBEDDED_MPV_FRAME_COPY_ENV] ?? '')
.trim()
.toLowerCase()
);
}
private isFrameCopyEngineActive(): boolean {
// Requires the helper binary too: a stale opt-in (cleaned native
// build, bad install) must fall back to the native engine instead
// of leaving embedded MPV unsupported with no way to recover.
return (
this.isFrameCopyEngineRequested() &&
isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath())
);
}
getActiveEngine(): EmbeddedMpvEngine {
return this.isFrameCopyEngineActive() ? 'frame-copy' : 'native';
}
isFrameCopyAvailable(): boolean {
return isFrameCopyRuntimeUsable(() =>
this.resolveFrameCopyHelperPath()
);
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
return this.frameCopyAdapter?.getFrameSource(sessionId) ?? null;
}
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
if (!this.frameCopyAdapter) {
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => this.resolveFrameCopyHelperPath(),
getScaleFactor: () => this.getMainWindowScaleFactor(),
onFrameSourceChanged: (sessionId, source) => {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return;
}
App.mainWindow.webContents.send(
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
{ sessionId, source }
);
},
});
}
return this.frameCopyAdapter;
}
private resolveFrameCopyHelperPath(): string | null {
// Shared with the startup sandbox gate; kept as an instance method so
// service tests can stub helper discovery per scenario.
return resolveFrameCopyHelperPath();
}
private getMainWindowScaleFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return 1;
}
return screen.getDisplayMatching(App.mainWindow.getBounds())
.scaleFactor;
} catch {
return 1;
}
}
private detectCapabilities(): EmbeddedMpvCapabilities {
if (this.isFrameCopyEngineActive()) {
return {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: true,
};
}
const addon = this.addon;
return {
subtitles: typeof addon?.setSubtitleTrack === 'function',
@@ -129,7 +223,7 @@ export class EmbeddedMpvNativeService {
};
}
if (!this.isEmbeddedMpvEnabled()) {
if (!isEmbeddedMpvFeatureEnabled()) {
return {
supported: false,
platform: process.platform,
@@ -147,6 +241,19 @@ export class EmbeddedMpvNativeService {
};
}
// 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.
if (this.isFrameCopyEngineActive()) {
return {
supported: true,
platform: process.platform,
engine: 'frame-copy',
frameCopyAvailable: true,
capabilities: this.detectCapabilities(),
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
@@ -160,6 +267,8 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -224,6 +333,8 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -254,6 +365,8 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -292,6 +405,7 @@ export class EmbeddedMpvNativeService {
});
this.ensurePolling();
this.ensureRendererLifecycleWatch();
return (
this.refreshSession(sessionId) ?? {
id: sessionId,
@@ -541,6 +655,50 @@ export class EmbeddedMpvNativeService {
}, 500);
}
/**
* Sessions are torn down by the renderer's Angular lifecycle, which
* never runs when the renderer crashes or hard-reloads (dev-server HMR,
* Cmd+R). Without this watch, native mpv handles or frame-copy helper
* processes leak until app shutdown.
*/
private rendererWatchInstalled = false;
private ensureRendererLifecycleWatch(): void {
if (
this.rendererWatchInstalled ||
!App.mainWindow ||
App.mainWindow.isDestroyed()
) {
return;
}
this.rendererWatchInstalled = true;
const disposeAll = (reason: string) => {
if (this.sessions.size === 0) {
return;
}
console.warn(
`[Embedded MPV] Disposing ${this.sessions.size} session(s): renderer ${reason}`
);
[...this.sessions.keys()].forEach((sessionId) => {
try {
this.disposeSession(sessionId);
} catch {
// best-effort reaping; polling cleanup handles the rest
}
});
};
App.mainWindow.webContents.on('render-process-gone', (_event, details) =>
disposeAll(`process gone (${details.reason})`)
);
// Full navigations/reloads only — in-app Angular routing emits
// did-navigate-in-page and must not kill the active session.
App.mainWindow.webContents.on('did-navigate', () =>
disposeAll('reloaded')
);
}
private stopPollingIfIdle(): void {
if (this.sessions.size > 0 || !this.pollingTimer) {
return;
@@ -595,6 +753,13 @@ export class EmbeddedMpvNativeService {
typeof snapshot.aspectOverride === 'string'
? snapshot.aspectOverride
: 'no',
...(typeof snapshot.videoWidth === 'number' &&
typeof snapshot.videoHeight === 'number'
? {
videoWidth: snapshot.videoWidth,
videoHeight: snapshot.videoHeight,
}
: {}),
recording: snapshot.recording ?? { active: false },
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
@@ -757,20 +922,8 @@ export class EmbeddedMpvNativeService {
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
}
private isExperimentEnabled(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '')
.trim()
.toLowerCase()
);
}
private isEmbeddedMpvEnabled(): boolean {
return app.isPackaged || this.isExperimentEnabled();
}
private assertEmbeddedMpvEnabled(): void {
if (!this.isEmbeddedMpvEnabled()) {
if (!isEmbeddedMpvFeatureEnabled()) {
throw new Error(
`Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local desktop harness, or use a packaged build with the bundled runtime.`
);
@@ -841,6 +994,10 @@ export class EmbeddedMpvNativeService {
}
private getAddon(): NativeEmbeddedMpvAddon {
if (this.isFrameCopyEngineActive()) {
return this.getFrameCopyAdapter();
}
if (this.addon) {
return this.addon;
}
@@ -893,47 +1050,7 @@ export class EmbeddedMpvNativeService {
}
private getAddonCandidatePaths(): string[] {
const localBuildAddonPath = path.resolve(
process.cwd(),
'apps/electron-backend/native/build/Release/embedded_mpv.node'
);
const distAddonPaths = [
path.resolve(__dirname, 'native/embedded_mpv.node'),
path.resolve(__dirname, '../../native/embedded_mpv.node'),
];
const packagedAddonPaths = [
path.resolve(
(process as NodeJS.Process & { resourcesPath?: string })
.resourcesPath ?? '',
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
),
app.getAppPath()
? path.join(
path.dirname(app.getAppPath()),
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
)
: undefined,
];
return dedupePaths(
app.isPackaged
? [
...packagedAddonPaths,
...distAddonPaths,
localBuildAddonPath,
]
: [
localBuildAddonPath,
...distAddonPaths,
...packagedAddonPaths,
]
);
return getEmbeddedMpvAddonCandidatePaths();
}
private readUnavailableReason(candidatePaths: string[]): string | null {
@@ -0,0 +1,53 @@
const mockElectronApp = {
isPackaged: false,
};
jest.mock('electron', () => ({ app: mockElectronApp }));
import { isEmbeddedMpvFeatureEnabled } from './embedded-mpv-runtime-policy.util';
describe('embedded-mpv-runtime-policy.util', () => {
const originalExperiment =
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
afterEach(() => {
mockElectronApp.isPackaged = false;
if (originalExperiment === undefined) {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
} else {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT =
originalExperiment;
}
});
it('enables embedded MPV for packaged apps', () => {
mockElectronApp.isPackaged = true;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
expect(isEmbeddedMpvFeatureEnabled()).toBe(true);
});
it.each(['1', 'true', 'yes', 'on'])(
'enables embedded MPV for a truthy development opt-in: %s',
(value) => {
mockElectronApp.isPackaged = false;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = value;
expect(isEmbeddedMpvFeatureEnabled()).toBe(true);
}
);
it.each([undefined, '0', 'false', 'off'])(
'keeps embedded MPV disabled for an unpackaged run with opt-in %s',
(value) => {
mockElectronApp.isPackaged = false;
if (value === undefined) {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
} else {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = value;
}
expect(isEmbeddedMpvFeatureEnabled()).toBe(false);
}
);
});
@@ -0,0 +1,19 @@
import { app } from 'electron';
export const EMBEDDED_MPV_EXPERIMENT_ENV =
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
function isTruthy(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(value ?? '').trim().toLowerCase()
);
}
/**
* Packaged desktop builds enable embedded MPV by default. Unpackaged runs
* require the regular experiment opt-in before any embedded-MPV engine may
* start or relax BrowserWindow security settings.
*/
export function isEmbeddedMpvFeatureEnabled(): boolean {
return app.isPackaged || isTruthy(process.env[EMBEDDED_MPV_EXPERIMENT_ENV]);
}
@@ -8,6 +8,13 @@ export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH';
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
* 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.
*/
export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
export type StoreType = {
[WINDOW_BOUNDS]: Electron.Rectangle;
@@ -17,6 +24,7 @@ export type StoreType = {
[VLC_PLAYER_ARGUMENTS]: string;
[MPV_REUSE_INSTANCE]: boolean;
[VLC_REUSE_INSTANCE]: boolean;
[EMBEDDED_MPV_FRAME_COPY]: boolean;
};
// Export singleton store instance
+25
View File
@@ -31,6 +31,15 @@ import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
isFrameCopyRuntimeUsable,
shouldPromotePersistedFrameCopyOptIn,
} from './app/services/embedded-mpv-frame-copy-platform.util';
import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util';
import {
EMBEDDED_MPV_FRAME_COPY,
store,
} from './app/services/store.service';
app.setName('iptvnator');
@@ -55,6 +64,22 @@ if (electronUserDataPath) {
app.setPath('userData', electronUserDataPath);
}
// The frame-copy embedded MPV engine must be decided before the main window
// exists: it relaxes the window sandbox for the preload frame pump, and
// webPreferences are fixed at window creation. The Settings toggle persists
// to the main-process config store; an explicitly set env var (including
// '0') always wins so dev/CI behavior stays scriptable.
if (
isEmbeddedMpvFeatureEnabled() &&
shouldPromotePersistedFrameCopyOptIn(
store.get(EMBEDDED_MPV_FRAME_COPY, false),
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
isFrameCopyRuntimeUsable()
)
) {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
}
let fixPathScheduled = false;
/**
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
"types": ["node"],
"importHelpers": false
},
"files": ["../../global.d.ts"],
"exclude": [
@@ -72,6 +72,7 @@ export function createSettingsForm(
],
],
recordingFolder: '',
embeddedMpvFrameCopy: false,
coverSize: 'medium' as CoverSize,
...(supportsEpg
? {
@@ -135,6 +136,7 @@ export function createSettingsFromFormValue(
remoteControl: value.remoteControl ?? false,
remoteControlPort: Number(value.remoteControlPort ?? 8765),
recordingFolder: value.recordingFolder ?? '',
embeddedMpvFrameCopy: value.embeddedMpvFrameCopy ?? false,
coverSize: value.coverSize ?? 'medium',
epgUrl,
preferUploadedEpgOverXtream:
@@ -114,6 +114,42 @@
</div>
</div>
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
<div
class="setting-item"
data-test-id="embedded-mpv-frame-copy-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.EMBEDDED_MPV_FRAME_COPY' | translate }}
</h4>
<p>
{{
'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION'
| translate
}}
@if (
form().value.embeddedMpvFrameCopy !==
frameCopyActive()
) {
<strong>
{{
'SETTINGS.EMBEDDED_MPV_FRAME_COPY_RESTART'
| translate
}}
</strong>
}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="embeddedMpvFrameCopy"
data-test-id="embedded-mpv-frame-copy-toggle"
></mat-checkbox>
</div>
</div>
}
<div class="setting-item" data-test-id="recording-folder-setting">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
@@ -45,6 +45,10 @@ export class SettingsPlaybackSectionComponent {
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
/** Frame-copy embedded MPV engine is possible on this machine */
readonly frameCopyAvailable = input(false);
/** Frame-copy engine is what the current app run actually uses */
readonly frameCopyActive = input(false);
readonly supportsManagedExternalPlayers = input(false);
readonly supportsExternalPlayerPathSettings = input(false);
readonly selectRecordingFolder = output<void>();
@@ -50,6 +50,8 @@
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
[frameCopyAvailable]="frameCopyAvailable()"
[frameCopyActive]="frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@@ -102,6 +102,7 @@ const DEFAULT_SETTINGS = {
remoteControlPort: 8765,
epgUrl: [],
recordingFolder: '',
embeddedMpvFrameCopy: false,
coverSize: 'medium',
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
@@ -165,6 +165,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
);
readonly frameCopyAvailable = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.frameCopyAvailable
);
readonly frameCopyActive = computed(
() => this.embeddedMpvSupport()?.engine === 'frame-copy'
);
readonly isPwa = this.runtime.isPwa;
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
},
"THEMES": {
"DARK_THEME": "داكن",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
},
"THEMES": {
"DARK_THEME": "داكن",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
},
"THEMES": {
"DARK_THEME": "Цёмная",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Wird nach einem Neustart der App wirksam."
},
"THEMES": {
"DARK_THEME": "Dunkel",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
},
"THEMES": {
"DARK_THEME": "Σκούρο",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it. macOS Apple Silicon only.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
},
"THEMES": {
"DARK_THEME": "Dark",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Se aplica tras reiniciar la aplicación."
},
"THEMES": {
"DARK_THEME": "Oscuro",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Prend effet après le redémarrage de l'application."
},
"THEMES": {
"DARK_THEME": "Sombre",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Ha effetto dopo il riavvio dell'app."
},
"THEMES": {
"DARK_THEME": "Scuro",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSのみ。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
},
"THEMES": {
"DARK_THEME": "ダーク",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 전용.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
},
"THEMES": {
"DARK_THEME": "어둡게",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Wordt van kracht na het herstarten van de app."
},
"THEMES": {
"DARK_THEME": "Donker",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
},
"THEMES": {
"DARK_THEME": "Ciemny",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Entra em vigor após reiniciar a aplicação."
},
"THEMES": {
"DARK_THEME": "Escuro",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Обновить",
"APP_UPDATE_INSTALL": "Перезагрузить приложение",
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Что нового"
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
},
"THEMES": {
"DARK_THEME": "Тёмная",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_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_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
},
"THEMES": {
"DARK_THEME": "Koyu",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
},
"THEMES": {
"DARK_THEME": "深色",
+4 -1
View File
@@ -428,7 +428,10 @@
"APP_UPDATE_DOWNLOAD": "Update",
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new"
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
},
"THEMES": {
"DARK_THEME": "深色",