mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
eb1bfaa474
commit
26271fc076
80 files changed
+5963
-89
No files matched your search
@@ -617,6 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Built-in HTML5 player with HLS.js or Video.js
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon only; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported runtime contains both an executable helper and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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": "داكن",
|
||||
|
||||
@@ -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": "داكن",
|
||||
|
||||
@@ -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": "Цёмная",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Σκούρο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ダーク",
|
||||
|
||||
@@ -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": "어둡게",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Тёмная",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -18,6 +18,15 @@ Source files for the embedded MPV integration:
|
||||
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
|
||||
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
|
||||
|
||||
Frame-copy engine sources (experimental, macOS Apple Silicon — see the
|
||||
"Frame-Copy Engine" section below):
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_io.h`, `frame_shm.h`).
|
||||
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API shm frame reader used by the preload frame pump.
|
||||
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` — helper-process adapter behind the `NativeEmbeddedMpvAddon` surface.
|
||||
- `apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts` — preload frame pump (shm → WebGL canvas).
|
||||
- `spikes/mpv-frame-copy/` — standalone spike, measurement log (RESULTS.md), integration design (DESIGN.md), and the original analysis (ANALYSIS.md).
|
||||
|
||||
Generated native-addon build output:
|
||||
|
||||
- `apps/electron-backend/native/build/`
|
||||
@@ -89,6 +98,104 @@ The MPV video surface is a native platform view/window, not a normal DOM element
|
||||
|
||||
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
|
||||
|
||||
## Frame-Copy Engine (Experimental, Apple Silicon Only)
|
||||
|
||||
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
|
||||
embedded MPV experiment flag) switches macOS/arm64 to a second rendering
|
||||
engine that replaces the native-view compositing entirely:
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a
|
||||
one-process-per-session libmpv host. It decodes (hwdec), renders
|
||||
offscreen at viewport size (headless CGL + async PBO readback ring),
|
||||
publishes BGRA frames into a POSIX shm seqlock ring
|
||||
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
|
||||
plays audio directly. Control protocol: tab-separated commands on stdin,
|
||||
JSON events on stdout; the `snapshot` event mirrors
|
||||
`NativeEmbeddedMpvSessionSnapshot`. Status semantics are ported from
|
||||
`embedded_mpv.mm`.
|
||||
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` —
|
||||
implements the same `NativeEmbeddedMpvAddon` surface over the helper
|
||||
process, so `EmbeddedMpvNativeService` (polling, diffing, power blocker,
|
||||
recording paths) is reused unchanged. The flag routes `getAddon()` to the
|
||||
adapter and support reports `engine: 'frame-copy'`.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API
|
||||
shm reader loaded by the preload frame pump
|
||||
(`apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts`): copy
|
||||
the newest complete frame into a reused ArrayBuffer once per rAF and
|
||||
upload it to a WebGL2 texture on the renderer's
|
||||
`<canvas data-embedded-mpv-frame>` (BGRA swizzle in the shader). Frame
|
||||
copies whose post-copy seqlock check reports a writer race are discarded
|
||||
without advancing the consumed sequence, so the next rAF retries instead
|
||||
of uploading partial pixels. Frame data never crosses the contextBridge;
|
||||
the bridge only exposes
|
||||
`attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`.
|
||||
- Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when
|
||||
`support.engine === 'frame-copy'` and skips the compositor workarounds —
|
||||
no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs
|
||||
and controls stack above the canvas as ordinary DOM. Bounds sync still
|
||||
runs: the helper re-renders at the new viewport size (device pixels via
|
||||
the display scale factor), including a forced current-frame render when a
|
||||
paused resize creates a fresh shared-memory generation.
|
||||
|
||||
Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine`
|
||||
checkbox (shown only when support reports `frameCopyAvailable`) persists to
|
||||
the main-process config store (`electron-conf`), which `main.ts` reads
|
||||
before creating the window and translates into the env flag; an explicitly
|
||||
set env var (including `0`) wins over the stored preference, but cannot bypass
|
||||
the platform/runtime safety gate. Frame-copy can relax the window sandbox only
|
||||
when embedded MPV itself is enabled for the current run (packaged app or the
|
||||
regular development experiment flag) and discovery finds both an executable
|
||||
(`X_OK`) helper and a readable regular frame-reader addon in the same native
|
||||
directory. Packaged discovery is limited to packaged resource locations and
|
||||
never falls through to writable cwd/dist development paths. A disabled base
|
||||
experiment keeps the renderer sandbox enabled and embedded MPV unavailable.
|
||||
When the base feature is enabled, a missing, mode-stripped, or incomplete
|
||||
frame-copy runtime keeps the sandbox enabled and falls back to the native
|
||||
engine.
|
||||
Changing the toggle requires an app restart because web preferences are fixed
|
||||
at window creation.
|
||||
|
||||
Rendering size: the helper renders at the **aspect-fit** size of the video
|
||||
(observed `dwidth`/`dheight`) inside the requested viewport and bumps a shm
|
||||
generation when it changes — letterbox bars are never baked into frames,
|
||||
frames stay as small as possible, and the canvas letterboxes with a
|
||||
transparent background (app surface shows at the sides; fullscreen keeps a
|
||||
black backdrop). Snapshots carry `videoWidth`/`videoHeight`.
|
||||
`IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY=<seconds>` passes through to mpv's
|
||||
`audio-delay` for lip-sync tuning until a calibration flow exists.
|
||||
|
||||
Lifecycle safety: `EmbeddedMpvNativeService` watches the main window for
|
||||
`render-process-gone` and `did-navigate` (full reloads) and disposes every
|
||||
session — Angular teardown never runs on a renderer crash/hard reload, and
|
||||
without the watch helper processes (or native mpv handles) would leak until
|
||||
app shutdown. Unexpected helper exits surface as a session `error`. macOS
|
||||
package validation requires `iptvnator_mpv_helper` and
|
||||
`embedded_mpv_frame_reader.node` next to the addon whenever the addon
|
||||
ships. The after-pack hook restores the helper's executable mode after the
|
||||
asset copy, and optional/skipped native rebuilds remove stale helper/reader
|
||||
artifacts before reporting frame-copy availability. This cleanup prevents
|
||||
known leftover build output; it is not a compatibility check for a complete
|
||||
but version-mismatched runtime pair.
|
||||
|
||||
Trade-offs and constraints:
|
||||
|
||||
- The frame-copy experiment flag can relax the BrowserWindow sandbox only
|
||||
while the base embedded-MPV feature is enabled (preload must
|
||||
`require` the reader addon); `contextIsolation` and
|
||||
`nodeIntegration:false` stay on. The sandbox story must be revisited
|
||||
before this engine can become a default — candidates: utilityProcess +
|
||||
MessagePort (costs one extra copy + GC churn since Electron ports clone
|
||||
ArrayBuffers) or a WebCodecs-based path.
|
||||
- Scope: Apple Silicon only by owner decision (2026-07-10); Intel Macs
|
||||
keep the native-view engine. Windows/Linux ports of the helper (WGL/EGL)
|
||||
are future work — the shm protocol and adapter are platform-agnostic.
|
||||
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
|
||||
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
|
||||
produce-to-upload latency, zero torn frames over a 10-minute run.
|
||||
- Helper crash isolation: an unexpected helper exit surfaces as a session
|
||||
`error` (renderer falls back); it can never take down the Electron main
|
||||
process, unlike in-process libmpv.
|
||||
|
||||
## Resume And Track Handling
|
||||
|
||||
`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers.
|
||||
|
||||
@@ -45,6 +45,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
epgUrl: [],
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
embeddedMpvFrameCopy: false,
|
||||
coverSize: 'medium',
|
||||
epgViewMode: 'timeline',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
@@ -187,6 +188,8 @@ export const SettingsStore = signalStore(
|
||||
recordingFolder:
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
embeddedMpvFrameCopy:
|
||||
store.embeddedMpvFrameCopy?.() ?? false,
|
||||
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
|
||||
@@ -909,6 +909,14 @@ export interface ElectronBridgeApi {
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
/**
|
||||
* Frame-copy engine only: start/stop the preload frame pump that
|
||||
* uploads helper frames onto the renderer's
|
||||
* `<canvas data-embedded-mpv-frame>` element. Optional because older
|
||||
* preload builds do not ship the pump.
|
||||
*/
|
||||
attachEmbeddedMpvFrameView?: (sessionId: string) => Promise<boolean>;
|
||||
detachEmbeddedMpvFrameView?: () => void;
|
||||
downloadsStart: (
|
||||
data: ElectronBridgeDownloadStartPayload
|
||||
) => Promise<ElectronBridgeDownloadStartResult>;
|
||||
|
||||
@@ -22,11 +22,38 @@ export interface EmbeddedMpvCapabilities {
|
||||
recording: boolean;
|
||||
}
|
||||
|
||||
export type EmbeddedMpvEngine = 'native' | 'frame-copy';
|
||||
|
||||
export interface EmbeddedMpvSupport {
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
reason?: string;
|
||||
capabilities?: EmbeddedMpvCapabilities;
|
||||
/**
|
||||
* Rendering engine the main process will use for new sessions.
|
||||
* `native` = platform video surface (NSOpenGLView/HWND/X11 wid),
|
||||
* `frame-copy` = helper process + shm ring + renderer canvas.
|
||||
*/
|
||||
engine?: EmbeddedMpvEngine;
|
||||
/**
|
||||
* True when this machine could run the frame-copy engine (macOS arm64
|
||||
* with the helper binary present), regardless of whether it is active.
|
||||
* Drives the Settings toggle; switching engines requires an app restart.
|
||||
*/
|
||||
frameCopyAvailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the renderer's frame pump finds the current shm frame ring of a
|
||||
* frame-copy session. A new generation is announced after every viewport
|
||||
* resize; the pump re-attaches to the new segment.
|
||||
*/
|
||||
export interface EmbeddedMpvFrameSource {
|
||||
shmName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
generation: number;
|
||||
readerPath: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvAudioTrack {
|
||||
@@ -66,6 +93,9 @@ export interface EmbeddedMpvSession {
|
||||
selectedSubtitleTrackId: number | null;
|
||||
playbackSpeed: number;
|
||||
aspectOverride: string;
|
||||
/** Source video size (mpv dwidth/dheight); frame-copy engine only. */
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
recording?: EmbeddedMpvRecordingState;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -66,6 +66,9 @@ export const EMBEDDED_MPV_SELECT_RECORDING_FOLDER =
|
||||
export const EMBEDDED_MPV_DISPOSE_SESSION = 'EMBEDDED_MPV_DISPOSE_SESSION';
|
||||
export const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
|
||||
export const EMBEDDED_MPV_PREPARE = 'EMBEDDED_MPV_PREPARE';
|
||||
export const EMBEDDED_MPV_GET_FRAME_SOURCE = 'EMBEDDED_MPV_GET_FRAME_SOURCE';
|
||||
export const EMBEDDED_MPV_FRAME_SOURCE_CHANGED =
|
||||
'EMBEDDED_MPV_FRAME_SOURCE_CHANGED';
|
||||
|
||||
// Xtream
|
||||
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
|
||||
|
||||
@@ -108,6 +108,12 @@ export interface Settings {
|
||||
downloadFolder?: string;
|
||||
/** Custom live recording folder path (uses system Downloads folder if not set) */
|
||||
recordingFolder?: string;
|
||||
/**
|
||||
* Embedded MPV frame-copy engine (experimental, macOS Apple Silicon).
|
||||
* Applied on the next app start — the engine relaxes the window sandbox
|
||||
* for its preload frame pump, which is fixed at window creation.
|
||||
*/
|
||||
embeddedMpvFrameCopy?: boolean;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
class="embedded-mpv-player__viewport"
|
||||
(click)="onViewportClick($event)"
|
||||
>
|
||||
@if (isFrameCopyEngine()) {
|
||||
<!-- Frame-copy engine: the preload frame pump paints helper
|
||||
frames onto this canvas. It is ordinary DOM, so controls
|
||||
and dialogs stack above it without native-layer tricks. -->
|
||||
<canvas
|
||||
class="embedded-mpv-player__frame-canvas"
|
||||
data-embedded-mpv-frame
|
||||
></canvas>
|
||||
}
|
||||
@if (isErrored()) {
|
||||
<div
|
||||
class="embedded-mpv-player__stalled"
|
||||
|
||||
@@ -51,6 +51,21 @@
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
// Frame-copy engine: video frames are painted onto this canvas by the
|
||||
// preload frame pump. The helper renders at the aspect-fit size of the
|
||||
// video inside the viewport (no letterbox bars are baked into frames), so
|
||||
// object-fit: contain centers the content and the sides show the app
|
||||
// surface instead of TV-style black bars. Fullscreen keeps a black
|
||||
// backdrop via the viewport rule above.
|
||||
.embedded-mpv-player__frame-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport {
|
||||
bottom: var(--embedded-mpv-controls-height);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly aspectPresets = ASPECT_PRESETS;
|
||||
|
||||
readonly isSupported = computed(() => this.support()?.supported ?? false);
|
||||
readonly isFrameCopyEngine = computed(
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
readonly capabilities = computed(
|
||||
() =>
|
||||
this.support()?.capabilities ?? {
|
||||
@@ -343,6 +346,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
this.controller.setBoundsProvider((host) => {
|
||||
// The frame-copy engine paints into an ordinary DOM canvas:
|
||||
// dialogs and popovers stack above it natively, so the
|
||||
// hide-offscreen and popover-cutout compositor workarounds
|
||||
// must not shrink its render size.
|
||||
if (this.isFrameCopyEngine()) {
|
||||
return measureBounds(host);
|
||||
}
|
||||
if (this.overlayVisibility.overlayActive()) {
|
||||
return HIDDEN_BOUNDS;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export class EmbeddedMpvSessionController {
|
||||
readonly stalled = signal(false);
|
||||
readonly retryToken = signal(0);
|
||||
|
||||
readonly isFrameCopyEngine = computed(
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
|
||||
private readonly sessionStatus = computed(
|
||||
() => this.session()?.status ?? null
|
||||
);
|
||||
@@ -182,6 +186,24 @@ export class EmbeddedMpvSessionController {
|
||||
this.sessionId.set(created.id);
|
||||
this.session.set(created);
|
||||
await electron.loadEmbeddedMpvPlayback(created.id, playback);
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
// Frame-copy engine: start the preload frame pump that
|
||||
// paints helper frames onto the component's canvas. A failed
|
||||
// attach (no canvas, no WebGL2, reader missing) must surface
|
||||
// as a session error — otherwise the helper keeps playing
|
||||
// audio behind a black canvas with no recovery UI.
|
||||
const attached = await electron
|
||||
.attachEmbeddedMpvFrameView?.(created.id)
|
||||
.catch(() => false);
|
||||
if (attached === false && !disposed) {
|
||||
await electron
|
||||
.disposeEmbeddedMpvSession(created.id)
|
||||
.catch(() => undefined);
|
||||
throw new Error(
|
||||
'The embedded MPV frame view failed to initialize.'
|
||||
);
|
||||
}
|
||||
}
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
@@ -217,6 +239,9 @@ export class EmbeddedMpvSessionController {
|
||||
this.session.set(null);
|
||||
|
||||
if (id) {
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
window.electron?.detachEmbeddedMpvFrameView?.();
|
||||
}
|
||||
void window.electron?.disposeEmbeddedMpvSession(id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
build/
|
||||
media/
|
||||
results-*.txt
|
||||
@@ -0,0 +1,107 @@
|
||||
# Embedded MPV: frame-copy unification — analysis handoff
|
||||
|
||||
> Committed for history: this is the 2026-07-10 analysis that kicked off the
|
||||
> spike and the integration on this branch. Current state lives in
|
||||
> README.md/RESULTS.md/DESIGN.md next to this file and in
|
||||
> `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
|
||||
Status: **analysis/decision doc, no code yet.** This distills a working session
|
||||
(2026-07-10) comparing the embedded-MPV approaches and designing the next step.
|
||||
A fresh agent/developer session should be able to continue from this file alone.
|
||||
|
||||
## Context
|
||||
|
||||
- Contributor **larsemig** proposed a unified player-controls architecture in
|
||||
[PR #1105](https://github.com/4gray/iptvnator/pull/1105) (docs-only, direction
|
||||
approved) and implemented it as a stacked 7-PR series:
|
||||
[#1148](https://github.com/4gray/iptvnator/pull/1148) shared controls layer,
|
||||
[#1149](https://github.com/4gray/iptvnator/pull/1149) session-controller decompose,
|
||||
[#1150](https://github.com/4gray/iptvnator/pull/1150) immersive overlay (macOS),
|
||||
[#1151](https://github.com/4gray/iptvnator/pull/1151) native macOS fullscreen,
|
||||
[#1152](https://github.com/4gray/iptvnator/pull/1152)–[#1154](https://github.com/4gray/iptvnator/pull/1154)
|
||||
HTML5/Video.js/ArtPlayer on shared controls (flag `WEB_PLAYER_SHARED_CONTROLS`, default OFF).
|
||||
- All 7 PRs are OPEN (as of 2026-07-10). Master still has the pre-series
|
||||
("docked controls") approach documented in `docs/architecture/embedded-mpv-native.md`.
|
||||
|
||||
## Comparison verdict
|
||||
|
||||
The series is really **two independent things**:
|
||||
|
||||
1. **Shared engine-agnostic controls contract** (`PlayerController`:
|
||||
capabilities/state/commands + thin per-engine adapters) — low-risk, valuable,
|
||||
survives any change of rendering mechanics. Recommendation: merge PRs 1, 2, 5, 6, 7.
|
||||
2. **Immersive compositing** (native mpv surface `NSWindowBelow` the WebContents,
|
||||
`transparent:true` main window, structural CSS "tunnel" via
|
||||
`:has(app-embedded-mpv-player)`, backdrop-with-hole) — the risky part.
|
||||
Two field bugs already found by 4gray in #1154:
|
||||
- opaque page wrappers painting black over the video (fixed structurally);
|
||||
- transparent "hole to the desktop" until the user scrolls — a Chromium
|
||||
compositor damage-tracking bug class, not fixable app-side.
|
||||
Worst for prod: `transparent:true` is set at window creation and therefore
|
||||
applies to **all macOS users unconditionally**, flag or no flag.
|
||||
Recommendation: do not ship PRs 3–4 as-is; keep as experiment.
|
||||
|
||||
## Next step: frame-copy prototype (larsemig's own follow-up idea)
|
||||
|
||||
Idea from [his comment on #1154](https://github.com/4gray/iptvnator/pull/1154#issuecomment-4932807350):
|
||||
render natively in the background, copy frames into the web UI, drop the
|
||||
transparent window entirely. Assessment: correct direction — trades
|
||||
unpredictable compositor bugs for a predictable performance tax, and it is the
|
||||
only realistic path to one architecture on macOS/Windows/Linux.
|
||||
|
||||
### Target architecture (prototype the end-state directly)
|
||||
|
||||
```
|
||||
mpv-helper process (links libmpv) Electron
|
||||
decode (hwdec=*-copy) native addon: map shm,
|
||||
render offscreen GL FBO -> async PBO memcpy frame -> ArrayBuffer
|
||||
readback (or SW render) renderer: WebGL texture upload
|
||||
audio -> OS directly in rAF -> <canvas>; controls = DOM
|
||||
| ^
|
||||
+---- shared-memory ring buffer ---------+
|
||||
+---- JSON IPC (reuse Linux protocol in
|
||||
embedded_mpv_wid_common.h) -------->
|
||||
```
|
||||
|
||||
Key design points:
|
||||
|
||||
- **Helper process, not in-process libmpv**: in-process is forbidden on Linux
|
||||
(ffmpeg/GL symbol clash with Electron — see `embedded-mpv-native.md`), and a
|
||||
helper gives crash isolation everywhere + lets Linux finally bundle libmpv
|
||||
(full feature set, Flatpak/Snap become possible, no system-mpv-on-PATH).
|
||||
- **Render at viewport size, not source size** — mpv scales on GPU before
|
||||
readback; 4K source in a 720p window costs 720p. Full price only in 4K fullscreen.
|
||||
- **Audio untouched** (helper plays directly); compensate video-path latency
|
||||
with calibrated mpv `--audio-delay` to restore lip-sync.
|
||||
- **3-frame ring buffer** in shm; reader takes latest complete frame, drops
|
||||
stale ones (prevents latency accumulation).
|
||||
- The controls layer from the PR series is reused as-is (adapter over the same
|
||||
`PlayerController` contract); tunnel/backdrop/immersive code becomes unnecessary.
|
||||
|
||||
### 4K numbers and risks
|
||||
|
||||
- 3840×2160 RGBA = 33 MB/frame; ~2 GB/s per copy at 50–60 fps; 2–3 copies in
|
||||
the path (readback, shm→buffer, texture upload).
|
||||
- Added latency ≈ 2–3 frames (~40–60 ms): async PBO readback +1 frame, memcpy
|
||||
~6–8 ms, rAF alignment 0–17 ms, Chromium compositor +1 frame. Fixed latency
|
||||
is NOT the problem for IPTV (audio-delay compensates lip-sync).
|
||||
- Real risks: **frame pacing/judder** on weak hardware (Intel iGPU, old
|
||||
Intel Macs) if the per-frame budget isn't met consistently; **50→60 Hz
|
||||
cadence** judder (mpv's display-resync is lost with manual rAF presentation);
|
||||
**HDR** must be tonemapped (canvas is SDR); battery cost of extra GB/s.
|
||||
|
||||
### Prototype plan and go/no-go gates
|
||||
|
||||
1. Spike without Electron (~days): helper renders to shm; bare Chromium page draws.
|
||||
2. Measure on: Apple Silicon, Intel Mac, mid-range Windows laptop (iGPU).
|
||||
Scenarios: 1080p60, 4K25/50 SDR, 4K HDR; windowed + fullscreen.
|
||||
3. Gates: sustained fps with low frame-time variance (not average fps!),
|
||||
CPU/GPU %, end-to-end latency (flash test), battery drain.
|
||||
4. Only on green numbers: integrate behind its own flag; docked path stays as fallback.
|
||||
If weak hardware fails: still viable as main path with auto-fallback to
|
||||
docked/external player for 4K fullscreen.
|
||||
|
||||
## Coordination
|
||||
|
||||
larsemig offered to hand over or assist (see #1154 thread). Suggested reply:
|
||||
convert the offer into a joint frame-copy spike rather than a branch handover.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Frame-copy engine — integration design (draft)
|
||||
|
||||
Status: **draft for discussion**, based on the spike in this directory
|
||||
(measurements: `RESULTS.md`; analysis:
|
||||
`.plans/2026-07-10-embedded-mpv-frame-copy-unification.md`). Not committed
|
||||
to `docs/architecture/` yet because nothing here ships; graduate it there
|
||||
when integration starts.
|
||||
|
||||
## Goal
|
||||
|
||||
One embedded-playback rendering architecture on macOS, Windows, and Linux:
|
||||
mpv renders in a helper process, frames are copied into the web UI as a
|
||||
normal `<canvas>`. This replaces, eventually:
|
||||
|
||||
- macOS: `NSOpenGLView` layered under/over Chromium (fragile against OS
|
||||
compositor changes — see the macOS 26.2 layering regression) and the
|
||||
immersive `transparent:true` experiment (PRs #1150–#1151);
|
||||
- Windows: `--wid` against a child `HWND` (native surface stacking, no DOM
|
||||
overlays);
|
||||
- Linux: out-of-process `mpv --wid=<x11-window>` (X11-only, system mpv
|
||||
required, reduced feature set).
|
||||
|
||||
## Process model
|
||||
|
||||
- `iptvnator-mpv-helper` — a per-session process shipped with the app,
|
||||
linking the bundled libmpv on **all three platforms**. In-process libmpv
|
||||
is forbidden on Linux (ffmpeg/GL symbol clash with Electron), but a
|
||||
helper process is exactly the isolation that makes it legal — so Linux
|
||||
finally gets the full libmpv feature set (subtitles, speed, aspect,
|
||||
recording), native-Wayland stops mattering (no window embedding at all),
|
||||
and Flatpak/Snap become possible.
|
||||
- Spawned/owned by the existing `EmbeddedMpvNativeService` successor in the
|
||||
Electron main process. Helper crash = session `error` event + UI fallback;
|
||||
the Electron process is never taken down by libmpv.
|
||||
- The native addon shrinks to a thin shm reader (map + copyLatest), as in
|
||||
the spike. Process spawning/control needs no native code (Node
|
||||
`child_process` + stdio).
|
||||
|
||||
## Control protocol
|
||||
|
||||
- JSON lines over the helper's stdin/stdout, evolving the command set the
|
||||
Linux `--wid` backend already speaks over its socket
|
||||
(`embedded_mpv_wid_common.h`): `loadfile` (with start offset, headers,
|
||||
user-agent, referrer), `pause`, `seek`, `volume`, `aid`/`sid`/`speed`/
|
||||
`video-aspect-override`, `stream-record`, `quit`.
|
||||
- Unlike the Linux socket backend, the helper links libmpv, so it pushes
|
||||
property events (status, time-pos/duration, track-list, eof-reached)
|
||||
instead of being polled with a scalar-only parser. Status mapping
|
||||
(`ended` vs `idle` vs `error`, `MPV_END_FILE_REASON_*`, `eof-reached`
|
||||
with `keep-open`) is ported unchanged from `embedded_mpv.mm`.
|
||||
- The main process keeps translating to the existing
|
||||
`EmbeddedMpvSession` contract — the preload/renderer API surface does not
|
||||
change, so `EmbeddedMpvPlayerComponent` and the PR-series
|
||||
`PlayerController` adapter sit on top unmodified.
|
||||
|
||||
## Video path
|
||||
|
||||
- shm ring exactly as in the spike: 3 slots, per-slot seqlock, BGRA frames
|
||||
at **viewport size** (claim verified: 4K source in a 720p viewport costs
|
||||
720p — RESULTS.md).
|
||||
- Renderer: WebGL2 canvas inside the player component, rAF-polling
|
||||
`latestSeq` (spike-proven at 60 fps; a wakeup channel is a later
|
||||
optimization, as is skipping redraws when no new frame arrived).
|
||||
- **This deletes the compositor-workaround machinery**: no bounds-sync on
|
||||
scroll/resize, no `HIDDEN_BOUNDS` when dialogs open, no 300 px popover
|
||||
cutout, no reserved control dock — controls and dialogs are ordinary DOM
|
||||
above a canvas.
|
||||
- Resize: renderer reports new bounds (debounced ~100 ms) → helper
|
||||
recreates FBO/PBOs and a new shm generation
|
||||
(`/iptvnator-mpv-<sessionId>-<gen>`); the viewer remaps on the control
|
||||
event announcing the new generation and keeps presenting the old frame
|
||||
(CSS-scaled) until the first new-generation frame lands.
|
||||
|
||||
## Audio and A/V sync
|
||||
|
||||
Audio never crosses the boundary — the helper plays it through the OS
|
||||
directly. The video path adds ~10 ms on M1 (measure per platform); a
|
||||
calibrated default `--audio-delay` compensates lip-sync. Calibration method
|
||||
(flash test) is still an open gate.
|
||||
|
||||
## HDR
|
||||
|
||||
mpv tonemaps HDR→SDR before readback (verified with 4K25 HDR10 PQ/BT.2020
|
||||
at full rate). The canvas stays SDR; HDR passthrough is explicitly out of
|
||||
scope for v1.
|
||||
|
||||
## Packaging
|
||||
|
||||
- macOS/Windows: the existing vendored-LGPL libmpv staging
|
||||
(`tools/embedded-mpv/`, `vendor/embedded-mpv/`) is reused as-is; the
|
||||
helper binary is one more artifact built per platform/arch next to
|
||||
`embedded_mpv.node`.
|
||||
- Linux: the helper links the **bundled** libmpv — the
|
||||
`external-mpv-process` manifest and the system-mpv-on-PATH requirement
|
||||
disappear. Linux staging switches from "headers only" to the same
|
||||
vendored-runtime flow as macOS.
|
||||
- Windows shm: POSIX `shm_open` on macOS/Linux, `CreateFileMapping` named
|
||||
sections on Windows behind the same header layout.
|
||||
|
||||
## Platform scope
|
||||
|
||||
macOS: **Apple Silicon (arm64) only** — owner decision 2026-07-10. Support
|
||||
detection gates on the CPU architecture; Intel Macs keep the existing
|
||||
docked native path, external players, and web engines. This closes the
|
||||
macOS hardware gate with the M1 Pro measurements and moves all remaining
|
||||
hardware risk to Windows/Linux.
|
||||
|
||||
## Platform bring-up order
|
||||
|
||||
1. macOS: spike is the implementation seed (CGL headless + PBO ring).
|
||||
2. Windows: WGL headless context (hidden window) + same GL readback path;
|
||||
`MPV_RENDER_API_TYPE_SW` is the fallback bring-up option if WGL fights,
|
||||
at the cost of CPU-side scaling.
|
||||
3. Linux: EGL headless (surfaceless platform) — also drops the
|
||||
Xwayland-only constraint.
|
||||
|
||||
## Rollout
|
||||
|
||||
- Behind its own experiment flag (settings + env), default OFF. The current
|
||||
docked native path stays the default until the hardware gates pass.
|
||||
- Relationship to the PR series: the shared-controls PRs (#1148, #1149,
|
||||
#1152–#1154) merge independently — the frame-copy player is just another
|
||||
`PlayerController` engine. The immersive/transparent-window PRs
|
||||
(#1150–#1151) stay experimental and are superseded by this path.
|
||||
|
||||
## Open gates before default-ON
|
||||
|
||||
- Windows iGPU numbers (needs the Windows helper port; RESULTS.md has the
|
||||
repro recipe). Intel Mac gate closed by the arm64-only scope decision.
|
||||
- Latency flash test + per-platform `--audio-delay` calibration.
|
||||
- Battery drain delta vs the native-surface approach.
|
||||
- Windows/Linux helper ports validated on real machines.
|
||||
@@ -0,0 +1,46 @@
|
||||
# mpv frame-copy spike (macOS only for now).
|
||||
#
|
||||
# Requires: Homebrew libmpv (brew install mpv) and Node >= 18 on PATH (for
|
||||
# its N-API headers). Both are auto-detected; override BREW_PREFIX/NODE_INC
|
||||
# if detection picks the wrong one. Works on Apple Silicon (/opt/homebrew)
|
||||
# and Intel (/usr/local) Homebrew layouts.
|
||||
|
||||
# libmpv prefix: Homebrew (Apple Silicon /opt/homebrew or Intel /usr/local)
|
||||
# or MacPorts (/opt/local, needed on legacy macOS like High Sierra where
|
||||
# Homebrew is unsupported — install with: sudo port install mpv +libmpv).
|
||||
MPV_PREFIX ?= $(shell \
|
||||
brew_p="$$(brew --prefix 2>/dev/null)"; \
|
||||
if [ -n "$$brew_p" ] && ls "$$brew_p"/lib/libmpv*.dylib >/dev/null 2>&1; then echo "$$brew_p"; \
|
||||
elif ls /opt/local/lib/libmpv*.dylib >/dev/null 2>&1; then echo /opt/local; \
|
||||
else echo /opt/homebrew; fi)
|
||||
BREW_PREFIX ?= $(MPV_PREFIX)
|
||||
# Self-contained bundles vendor the four N-API headers so no Node install
|
||||
# is needed; otherwise take headers from the PATH node.
|
||||
ifneq (,$(wildcard vendor/node-headers/node_api.h))
|
||||
NODE_INC ?= vendor/node-headers
|
||||
else
|
||||
NODE_INC ?= $(shell node -p "require('path').join(process.execPath, '..', '..', 'include', 'node')" 2>/dev/null)
|
||||
endif
|
||||
|
||||
BUILD := build
|
||||
|
||||
all: $(BUILD)/mpv_helper $(BUILD)/shm_reader.node
|
||||
|
||||
$(BUILD):
|
||||
mkdir -p $(BUILD)
|
||||
|
||||
$(BUILD)/mpv_helper: helper/mpv_helper.cpp common/spike_shm.h | $(BUILD)
|
||||
clang++ -std=c++17 -O2 -Wall -Icommon -I$(BREW_PREFIX)/include \
|
||||
-o $@ helper/mpv_helper.cpp \
|
||||
-L$(BREW_PREFIX)/lib -lmpv -Wl,-rpath,$(BREW_PREFIX)/lib \
|
||||
-framework OpenGL
|
||||
|
||||
$(BUILD)/shm_reader.node: viewer/addon/shm_reader.c common/spike_shm.h | $(BUILD)
|
||||
clang -std=c11 -O2 -Wall -Icommon -I$(NODE_INC) \
|
||||
-shared -fPIC -Wl,-undefined,dynamic_lookup \
|
||||
-o $@ viewer/addon/shm_reader.c
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD)
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -0,0 +1,209 @@
|
||||
# Frame-copy engine — Windows/Linux porting handoff
|
||||
|
||||
> Handoff for future Claude/dev sessions on Windows and Linux machines
|
||||
> (they won't have the originating Mac's local session memory — this file
|
||||
> is the transfer). Written 2026-07-11 by the macOS session that built the
|
||||
> engine; fold into DESIGN.md once both ports land.
|
||||
|
||||
## State as of this handoff
|
||||
|
||||
- Branch: `claude/embedded-mpv-frame-copy-6ddc36`, PR:
|
||||
https://github.com/4gray/iptvnator/pull/1169 (CI fully green).
|
||||
- The engine works end-to-end on macOS Apple Silicon: Settings toggle →
|
||||
restart → helper process renders mpv offscreen → shm ring → preload pump
|
||||
→ WebGL canvas. Verified live with real IPTV + Stalker VOD.
|
||||
- Scope decision: macOS = arm64 only (Intel Macs keep the native engine).
|
||||
Windows/Linux are NOT ported yet — this branch on those OSes behaves
|
||||
exactly like master (helper doesn't build there, engine can't activate,
|
||||
env flag falls back to native).
|
||||
- Coordination: PR #1169 credits larsemig's idea (#1154 comment 4932807350)
|
||||
and proposes the series merge plan: merge the shared-controls subset
|
||||
(#1148/#1149/#1152–54) rebased, supersede immersive (#1150/#1151) with
|
||||
this engine. **Do not touch the controls layer** until that lands.
|
||||
|
||||
## What "porting" means
|
||||
|
||||
Only the helper (and a small reader-addon branch) is platform-specific.
|
||||
The stdio protocol, shm layout, TS adapter, main-process service, preload
|
||||
pump, and Angular UI are shared and already shipped.
|
||||
|
||||
```
|
||||
apps/electron-backend/native/helper/
|
||||
├── mpv_frame_helper.cpp # portable: protocol, mpv session, snapshots
|
||||
├── frame_helper_io.h # portable: TSV-in/JSON-out, percent-encoding
|
||||
├── frame_shm.h # layout portable; POSIX shm calls are not
|
||||
└── frame_helper_render.h # macOS-ONLY: CGL headless GL + PBO + shm write
|
||||
apps/electron-backend/native/src/embedded_mpv_frame_reader.c
|
||||
# real impl under #ifdef __APPLE__, stub elsewhere
|
||||
```
|
||||
|
||||
Porting = give `frame_helper_render.h` a WGL/EGL twin, give the shm
|
||||
create/open a Windows twin, flip the TS gates, extend packaging.
|
||||
|
||||
## Branching & merge strategy (do this, not "commit to the current branch")
|
||||
|
||||
- **Never commit port work into `claude/embedded-mpv-frame-copy-6ddc36`** —
|
||||
that branch backs PR #1169, which is a frozen review/testing target
|
||||
(review fixes only).
|
||||
- Create a port branch off it: `claude/frame-copy-linux-port` (Windows
|
||||
later: branch off the Linux port branch if it reuses its portable
|
||||
clock/shm refactors — likely yes — otherwise off the frame-copy branch).
|
||||
- Open the port PR with **base = the frame-copy branch**, so the diff shows
|
||||
only the port. Merge order: #1169 → Linux PR → Windows PR. When #1169
|
||||
merges and its branch is deleted, GitHub retargets the stacked PR to
|
||||
master automatically.
|
||||
- Keep the stack at most one unmerged level deep; if #1169 gains review
|
||||
commits, rebase the port branch onto it early and often.
|
||||
- Commit incrementally within the port branch; land each platform's
|
||||
measurement rows in `RESULTS.md` in the same PR as its port.
|
||||
- If #1169 has already merged by the time you read this: branch off
|
||||
`master` instead and ignore the retargeting notes.
|
||||
|
||||
## Per-platform task lists
|
||||
|
||||
### Linux (do first — much closer to done)
|
||||
|
||||
1. **Render backend**: headless EGL (`EGL_PLATFORM_SURFACELESS_MESA` /
|
||||
`eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA)` with fallback to
|
||||
GBM) + the same FBO/PBO/readback code. GL entry points via
|
||||
`eglGetProcAddress` in mpv's `get_proc_address`.
|
||||
2. **shm**: POSIX `shm_open` works as-is. The ONLY blocker in shared code:
|
||||
`clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)` is **macOS-only** — replace
|
||||
with a portable `now_ns()` (`clock_gettime(CLOCK_MONOTONIC, ...)`) in
|
||||
`frame_helper_render.h`, `frame_shm` users, and the reader addon. Keep
|
||||
producer/consumer on the SAME clock.
|
||||
3. **Reader addon**: change `#ifdef __APPLE__` to also cover `__linux__`
|
||||
(code is already POSIX apart from the clock call).
|
||||
4. **binding.gyp**: helper target gets a linux branch — link system libmpv
|
||||
for dev first (`-lmpv`); bundled-libmpv runtime is a later packaging
|
||||
task. NOTE: the LINUX ADDON must still not link libmpv (that rule is for
|
||||
in-process only — the helper is a separate process, linking is the whole
|
||||
point and is legal there).
|
||||
5. **TS gates**: `isFrameCopyEngineActive`/`isFrameCopyAvailable` in
|
||||
`embedded-mpv-native.service.ts` (drop the darwin/arm64-only condition
|
||||
for linux), `EmbeddedMpvFrameCopyAdapter.isSupported`, Settings copy +
|
||||
i18n descriptions currently say "macOS Apple Silicon only".
|
||||
6. **Big prize**: no window embedding → native Wayland just works; later
|
||||
bundle libmpv → no system-mpv-on-PATH requirement → Flatpak/Snap. Update
|
||||
the Linux support matrix in `docs/architecture/embedded-mpv-native.md`
|
||||
when this lands.
|
||||
|
||||
### Windows
|
||||
|
||||
1. **Render backend**: WGL headless — create a hidden message-only window +
|
||||
dummy pixel format, `wglCreateContextAttribsARB` 3.2 core, then the same
|
||||
FBO/PBO path. `MPV_RENDER_API_TYPE_SW` is the fallback bring-up if WGL
|
||||
fights (CPU render, still proves the pipeline).
|
||||
2. **shm**: `CreateFileMapping(INVALID_HANDLE_VALUE, ...)` +
|
||||
`MapViewOfFile` behind the same `FrameShmHeader` layout. Name mapping:
|
||||
`Local\\impv-...` (session-local namespace). Reader addon gets the
|
||||
`#ifdef _WIN32` twin. Atomics: `std::atomic<uint64_t>` fine with MSVC;
|
||||
the C reader can use `InterlockedCompareExchange`-free plain
|
||||
`_Atomic`-equivalent via C11 `<stdatomic.h>` (clang-cl) or volatile+
|
||||
`MemoryBarrier` — simplest is compiling the reader as C++ on Windows.
|
||||
3. **Process control**: `child.kill('SIGTERM')` on Windows is
|
||||
TerminateProcess (no graceful signal) — the quit command + stdin-EOF
|
||||
paths (already implemented) are the graceful route; keep the kill as the
|
||||
hard fallback. stdio pipes work unchanged.
|
||||
4. **binding.gyp**: helper `.exe` target under `OS=="win"` linking the
|
||||
existing import lib (`LIBMPV_IMPORT_LIB` env — see build-embedded-mpv.js
|
||||
Windows path). DLL resolution: the helper exe sits next to `lib/` with
|
||||
the mpv DLL — either copy the DLL beside the exe at build time or call
|
||||
`SetDllDirectory`/`AddDllDirectory` at startup. Watch the documented
|
||||
import-library-vs-DLL-basename gotcha (embedded-mpv-native.md).
|
||||
5. **TS gates + audio**: same switches as Linux. WASAPI audio comes from
|
||||
mpv directly — nothing to do.
|
||||
6. **This is the open PERFORMANCE gate**: mid-range iGPU laptop numbers
|
||||
decide go/no-go (RESULTS.md has the methodology + reference M1 numbers:
|
||||
4K60 sustained, ~10 ms produce→upload, zero torn frames).
|
||||
|
||||
### Both platforms — shared chores
|
||||
|
||||
- `validatePackagedEmbeddedMpv` in `tools/packaging/embedded-mpv-packaging.cjs`
|
||||
currently requires frame-copy artifacts **on darwin only** — extend per
|
||||
platform when artifacts ship. Keep tests host-agnostic (CI runs them on
|
||||
a Linux runner; asserting an empty error list for a darwin dir fails
|
||||
there with "link validation must run on a macOS host" — already fixed
|
||||
once, don't regress).
|
||||
- `getMainWindowScaleFactor` (Electron `screen`) is cross-platform — no
|
||||
work needed; the helper receives device pixels.
|
||||
- Sandbox story: the flag relaxes the BrowserWindow sandbox for the preload
|
||||
reader require. Same trade-off applies on Win/Linux. Revisit-before-
|
||||
default-on candidates are in the architecture doc.
|
||||
|
||||
## Hard-won gotchas (do not rediscover these)
|
||||
|
||||
1. **Preload + tslib**: repo tsconfig has `target: es2015`; ANY construct
|
||||
that emits TS helpers in preload code (async/await, object spread in
|
||||
downlevel positions) with `importHelpers: true` makes webpack
|
||||
externalize `tslib` → the sandboxed preload dies with
|
||||
`module not found: tslib` → `window.electron` disappears app-wide.
|
||||
`apps/electron-backend/tsconfig.app.json` now sets
|
||||
`importHelpers: false` — NEVER revert it. Symptom to recognize:
|
||||
"Unable to load preload script" in renderer console.
|
||||
2. **V8 memory cage**: `napi_create_external_arraybuffer` over shm aborts
|
||||
in Electron. The reader MUST memcpy into a V8 buffer. Budgeted (~1.2 ms
|
||||
at 4K).
|
||||
3. **Frame orientation**: helper renders with `MPV_RENDER_PARAM_FLIP_Y=1`
|
||||
and `glReadPixels` reads rows bottom-up → the shm buffer is already in
|
||||
texture order. The pump shader samples with UN-flipped uv. Adding a
|
||||
second flip shows upside-down video (bug already made and fixed once).
|
||||
4. **BGRA fast path**: readback as `GL_BGRA`/`GL_UNSIGNED_INT_8_8_8_8_REV`,
|
||||
upload as RGBA, swizzle `.bgr` in the fragment shader. On Windows check
|
||||
whether BGRA readback stays the fast path per driver; measure, don't
|
||||
assume.
|
||||
5. **Aspect**: mpv reports unset `video-aspect-override` as `"-1.000000"`
|
||||
→ normalize to `"no"`. The helper aspect-fits the FBO to
|
||||
`dwidth`/`dheight` inside the viewport (no baked letterbox bars) and
|
||||
bumps a shm generation (`<base>-g<N>`) on every size change; the pump
|
||||
re-attaches via the FRAME_SOURCE_CHANGED event.
|
||||
6. **Stale attach race**: attach/detach bump a shared epoch in the pump;
|
||||
every await re-checks it. Keep that invariant if touching the pump.
|
||||
7. **Lifecycle**: dispose escalation is quit-command → stdin.end() (helper
|
||||
exits on EOF) → SIGTERM(500 ms) → SIGKILL(2 s). The SERVICE also reaps
|
||||
all sessions on `render-process-gone`/`did-navigate` (renderer crash or
|
||||
hard reload never runs Angular teardown — without this, helpers leak).
|
||||
Watch `ps | grep iptvnator_mpv_helper` during any manual test session.
|
||||
8. **Stale opt-in**: `isFrameCopyEngineActive()` requires the helper binary
|
||||
on disk; missing helper = silent fallback to native, and the Settings
|
||||
checkbox stays visible while the saved value is true so it can always
|
||||
be cleared.
|
||||
9. **node-gyp naming**: module targets emit `<target_name>.node` (no
|
||||
`product_name` needed); the helper uses the `"type": "none"` default +
|
||||
per-OS `"type": "executable"` override trick in binding.gyp.
|
||||
10. **snapshot protocol**: helper's `snapshot` JSON mirrors
|
||||
`NativeEmbeddedMpvSessionSnapshot` verbatim (volume 0..1, `null`able
|
||||
duration/track ids, `videoWidth/videoHeight` when known). Status
|
||||
semantics are ported from `embedded_mpv.mm` — END_FILE reason mapping,
|
||||
`eof-reached` ⇒ `ended` (keep-open), pause gated on loadedPath,
|
||||
only fatal/load errors flip status. Don't invent new mappings.
|
||||
|
||||
## Testing recipes
|
||||
|
||||
- **Helper standalone** (no Electron):
|
||||
`(printf 'load\turl=av://lavfi:testsrc2=size=640x360:rate=30\n'; sleep 5; printf 'quit\n') | ./iptvnator_mpv_helper --shm-base /impv-t --width 1280 --height 720`
|
||||
→ expect `shm` generations, `snapshot` events at 4 Hz, aspect-fit
|
||||
generation after video loads.
|
||||
- **Reader probe** (any Node ≥18):
|
||||
`node -e "const r=require('.../embedded_mpv_frame_reader.node'); const i=r.open('/impv-t-g2'); ..."`
|
||||
→ `latestSeq()` advancing + pixel min/max spread.
|
||||
- **In-app**: `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 pnpm run
|
||||
serve:backend:embedded-mpv` or the Settings toggle (+restart). Second
|
||||
parallel instance for CDP testing: build, then run
|
||||
`electron dist/apps/electron-backend/main.js --remote-debugging-port=9223
|
||||
--user-data-dir=/tmp/x` with `ELECTRON_IS_DEV=0` for the file:// renderer
|
||||
(dist package.json has no `main` field — point at main.js explicitly;
|
||||
a separate user-data-dir avoids the Chromium profile singleton).
|
||||
- **Perf gate**: follow `RESULTS.md` methodology (STATS/LONGRUN lines,
|
||||
present-interval sd/p99/late counters). Reference: M1 Pro tables therein.
|
||||
The spike harness in this directory is macOS-only; for Windows/Linux
|
||||
measure through the real app + helper stderr or port collect-results.sh.
|
||||
|
||||
## Suggested milestone order
|
||||
|
||||
1. Linux helper bring-up (EGL + portable clock) → lavfi smoke → in-app
|
||||
behind flag → measure.
|
||||
2. Windows helper bring-up (WGL, named shm, reader twin) → same ladder →
|
||||
**iGPU laptop numbers = the decisive open gate**.
|
||||
3. Packaging: per-platform artifact validation + runtime staging.
|
||||
4. Only then: revisit Linux bundled-libmpv + Flatpak/Snap story.
|
||||
@@ -0,0 +1,115 @@
|
||||
# mpv frame-copy spike
|
||||
|
||||
Standalone prototype for the frame-copy unification of embedded MPV
|
||||
(analysis: `.plans/2026-07-10-embedded-mpv-frame-copy-unification.md`).
|
||||
Instead of compositing a native mpv surface under a transparent Electron
|
||||
window, the helper renders mpv offscreen and copies frames into the web UI:
|
||||
|
||||
```
|
||||
mpv_helper (links libmpv) Electron viewer
|
||||
decode (hwdec) + offscreen GL FBO shm_reader.node: memcpy newest
|
||||
async PBO readback (3-deep ring) frame -> ArrayBuffer
|
||||
BGRA frames -> shm ring (3 slots) renderer: texSubImage2D in rAF
|
||||
audio -> OS directly -> WebGL canvas (BGRA swizzle)
|
||||
| ^
|
||||
+------ POSIX shm ring buffer ---------+
|
||||
```
|
||||
|
||||
macOS only for now. No Electron-app integration, no controls — this exists
|
||||
to measure whether the copy pipeline meets the go/no-go gates.
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires Homebrew `mpv` (libmpv + headers) and Node ≥ 18 on `PATH`; the
|
||||
Makefile auto-detects the Homebrew prefix (Apple Silicon and Intel) and the
|
||||
Node include dir.
|
||||
|
||||
Quick checklist for a fresh machine (e.g. the pending Intel Mac run):
|
||||
|
||||
```bash
|
||||
brew install mpv # libmpv + headers
|
||||
git clone <repo> && cd <repo>
|
||||
pnpm install --frozen-lockfile # provides the Electron binary for the viewer
|
||||
cd spikes/mpv-frame-copy
|
||||
./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60' # sanity
|
||||
# then the RESULTS.md scenarios (4K synthetic, 4K HEVC hwdec, 50 fps, HDR)
|
||||
```
|
||||
|
||||
```bash
|
||||
./run.sh /path/to/video.mkv 3840x2160
|
||||
./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60' # synthetic source
|
||||
HELPER_ARGS='--hwdec videotoolbox --loop' ./run.sh /tmp/clip.mp4 3840x2160
|
||||
```
|
||||
|
||||
`run.sh` builds both binaries, starts the helper, then opens the Electron
|
||||
viewer (binary auto-detected from the repo `node_modules`, override with
|
||||
`ELECTRON=`). The helper prints producer stats to stderr once per second;
|
||||
the viewer prints `STATS` lines to stdout every 2 s and shows the same in an
|
||||
on-screen HUD. `PIXELPROBE` is a one-shot image sanity check (spread > 0
|
||||
means real frames, not black).
|
||||
|
||||
Key design points implemented here:
|
||||
|
||||
- **Render at viewport size**: `--size WxH` is the FBO size; mpv scales on
|
||||
the GPU before readback, so a 4K source in a 720p viewport costs 720p.
|
||||
- **Async PBO readback**: `glReadPixels` goes into a 3-deep PBO ring; the
|
||||
previous frame's PBO is mapped/copied while the new readback is in flight.
|
||||
- **3-slot shm ring with per-slot seqlock**: the reader takes the newest
|
||||
complete frame and drops stale ones, so latency cannot accumulate.
|
||||
- **Audio never crosses the boundary**: the helper plays it directly.
|
||||
- **mpv keeps its own frame pacing**: the render call blocks for mpv's
|
||||
target time (default `block_for_target_time`), so the helper's `render ms`
|
||||
figure includes that pacing wait — it is not GPU cost.
|
||||
- **The addon must memcpy**: Electron's V8 memory cage forbids external
|
||||
ArrayBuffers over shm, so zero-copy into JS is impossible. The measured
|
||||
path is readback -> shm -> renderer ArrayBuffer -> GPU texture upload.
|
||||
|
||||
## First measurements (2026-07-10, MacBook Pro M1 Pro, 120 Hz display)
|
||||
|
||||
> Canonical measurement log (per machine, with repro commands): `RESULTS.md`.
|
||||
> Append future runs there — especially the pending Intel Mac / Windows iGPU
|
||||
> baselines.
|
||||
|
||||
| Scenario | Producer | Viewer new-frame fps | copy ms avg/p95 | upload ms avg/p95 | produce→uploaded age ms avg/p95 | torn |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1080p60 testsrc2 (sw) | 60.0 fps | 59.8 | 0.28 / 0.35 | 0.29 / 0.40 | 4.2 / 6.8 | 0 |
|
||||
| 4K60 testsrc2 (sw) | 60.0 fps | 60.0 | 1.17 / 1.35 | 3.8 / 4.5 | 11.0 / 12.7 | 0 |
|
||||
| 4K60 HEVC 25 Mbit (hwdec=videotoolbox) | 60.0 fps | 59.9 | 1.2 / 1.6 | 3.3 / 4.1 | 9.8 / 11.7 | 0 |
|
||||
|
||||
Helper-side PBO map+copy at 4K: ~1.0–1.8 ms avg. CPU during 4K60 HEVC:
|
||||
helper ~18 %, Electron renderer ~24 % of one core. The only fps dip observed
|
||||
coincided with the `--loop` file restart (decoder reinit), not the copy path.
|
||||
|
||||
Takeaway so far: on Apple Silicon the copy tax is far below the analysis
|
||||
doc's worst-case budget (33 MB memcpy ≈ 1.2 ms, not 6–8 ms; end-to-end added
|
||||
latency ≈ 10 ms, not 40–60 ms). The remaining open gates are weaker hardware
|
||||
and long-run pacing, not raw throughput on modern Macs.
|
||||
|
||||
## Gate status (details in RESULTS.md)
|
||||
|
||||
Measured on M1 Pro (2026-07-10):
|
||||
|
||||
- ✅ Throughput: 4K60 HEVC hwdec sustained end to end with ~10 ms added
|
||||
latency and single-digit-ms copies.
|
||||
- ✅ Frame pacing: viewer STATS now report per-interval stddev/p99/late
|
||||
counts (`present iv` / `src iv`, `LONGRUN` cumulative line every 30 s).
|
||||
50 fps and 25 fps cadences are clean; residual jitter is 120 Hz rAF grid
|
||||
quantization (≤ one display tick), not lost frames.
|
||||
- ✅ HDR: 4K25 HDR10 (PQ/BT.2020) tonemapped to SDR by mpv before readback
|
||||
at full rate with unchanged copy costs.
|
||||
|
||||
Still open:
|
||||
|
||||
- Mid-range Windows laptop (iGPU) — the actual risk hardware; needs the
|
||||
Windows helper port first. (Intel Macs: skipped by decision — on macOS
|
||||
the engine targets Apple Silicon only, see RESULTS.md.)
|
||||
- End-to-end latency flash test (photodiode/screen-capture method) and
|
||||
audio/video sync offset to calibrate `--audio-delay`.
|
||||
- Battery drain delta vs the native-surface approach.
|
||||
|
||||
## Known limitations of this spike
|
||||
|
||||
- macOS-only helper (CGL); Windows needs WGL/D3D, Linux EGL — same protocol.
|
||||
- `latestSeq`/`copyLatest` poll in rAF; no wakeup channel (fine at 60 fps).
|
||||
- Viewer canvas redraws every rAF tick even without a new frame.
|
||||
- No reconnect handling if the helper restarts (viewer keeps last mapping).
|
||||
@@ -0,0 +1,112 @@
|
||||
# Frame-copy spike — measurement log
|
||||
|
||||
One section per machine. Append new runs here; do not overwrite old ones —
|
||||
this file is the cross-hardware comparison baseline for the go/no-go
|
||||
decision (gates in README.md and
|
||||
`.plans/2026-07-10-embedded-mpv-frame-copy-unification.md`).
|
||||
|
||||
How to reproduce a row:
|
||||
|
||||
```bash
|
||||
# synthetic (no decode load):
|
||||
./run.sh 'av://lavfi:testsrc2=size=3840x2160:rate=60' 3840x2160
|
||||
# real 4K60 HEVC with hw decode (generate once):
|
||||
ffmpeg -y -f lavfi -i "testsrc2=size=3840x2160:rate=60" -t 12 \
|
||||
-c:v hevc_videotoolbox -b:v 25M -tag:v hvc1 -pix_fmt yuv420p /tmp/spike-4k-hevc.mp4
|
||||
HELPER_ARGS='--hwdec videotoolbox --no-audio --loop' ./run.sh /tmp/spike-4k-hevc.mp4 3840x2160
|
||||
```
|
||||
|
||||
Read `STATS` lines from viewer stdout after they stabilize (skip the first
|
||||
window); helper stats are on its stderr. CPU: `ps -o %cpu -p <pid>` for the
|
||||
helper and the `Electron Helper (Renderer)` process during playback.
|
||||
|
||||
Column meanings: *new fps* — frames actually reaching the canvas; *copy* —
|
||||
shm→ArrayBuffer memcpy in the addon; *upload* — `texSubImage2D` wall time;
|
||||
*age* — produce→uploaded latency (helper memcpy done → texture updated,
|
||||
same CLOCK_MONOTONIC_RAW clock).
|
||||
|
||||
## MacBook Pro M1 Pro (arm64), macOS, 120 Hz internal display — 2026-07-10
|
||||
|
||||
Source: commit `7e39d2e5`, libmpv 2.3.0 (Homebrew mpv 0.39.0), Electron 41.7.2.
|
||||
|
||||
| Scenario | Producer fps | New fps | copy ms avg/p95 | upload ms avg/p95 | age ms avg/p95 | torn | CPU helper / renderer |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1080p60 testsrc2, sw decode | 60.0 | 59.8 | 0.28 / 0.35 | 0.29 / 0.40 | 4.2 / 6.8 | 0 | — |
|
||||
| 4K60 testsrc2, sw decode | 60.0 | 60.0 | 1.17 / 1.35 | 3.8 / 4.5 | 11.0 / 12.7 | 0 | — |
|
||||
| 4K60 HEVC 25 Mbit, hwdec=videotoolbox | 60.0 | 59.9 | 1.2 / 1.6 | 3.3 / 4.1 | 9.8 / 11.7 | 0 | ~18 % / ~24 % |
|
||||
|
||||
Helper-side PBO map+copy at 4K: 1.0–1.8 ms avg. Only fps dip observed was at
|
||||
the `--loop` file restart (decoder reinit), not in the copy path.
|
||||
|
||||
### Pacing / judder and HDR (2026-07-10, same machine, viewer with interval instrumentation)
|
||||
|
||||
Metrics: *present iv sd* — stddev of intervals between texture uploads
|
||||
(viewer clock, rAF-quantized); *src iv sd* — stddev of intervals between
|
||||
produced frames (helper clock); *late1.5x* — present intervals > 1.5× the
|
||||
producer's median interval (a missed beat).
|
||||
|
||||
| Scenario | New fps | present iv sd | src iv sd | late (steady state) | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 4K60 HEVC hwdec (steady state) | 60.0 | 0.5–1.4 ms | 0.7–1.2 ms | 0–1 per 2 s | worst intervals only at `--loop` restart |
|
||||
| 1080p50 testsrc2 (50→120 Hz cadence) | 50.0 | ~4.1 ms | ~1.9 ms | 0; LONGRUN 30 s: 0.13 % (startup only) | sd is 120 Hz rAF grid quantization (16.7/25 ms alternation around 20 ms), not lost frames |
|
||||
| 1080p25 testsrc2 | 25.0 | ~4.1 ms | ~2.7 ms | 0 | same grid effect |
|
||||
| 4K25 HDR10 PQ/BT.2020 HEVC hwdec | 25.0 | ~5.5 ms | ~4.3 ms | 0 | mpv tonemaps to SDR before readback (PIXELPROBE spread 255); copy/upload costs unchanged |
|
||||
|
||||
### Viewport-size scaling check (2026-07-10)
|
||||
|
||||
Design claim "render at viewport size, pay viewport price" confirmed:
|
||||
the same 4K60 HEVC clip rendered into a 1280×720 FBO costs helper
|
||||
map+copy 0.17 ms (vs 1.5 ms at 4K), viewer copy 0.16 ms (vs 1.2),
|
||||
upload 0.17 ms (vs 3.5), steady 60 fps. Full 4K price is only paid in
|
||||
4K-sized viewports (i.e. fullscreen on a 4K display).
|
||||
|
||||
### 10-minute long run — 4K60 HEVC hwdec, `--loop` (2026-07-10)
|
||||
|
||||
Final cumulative line at t=574 s: 33 501 frames, avg 58.32 fps,
|
||||
late1.5x 0.475 %, late2.5x 0.20 %, worst interval 2306 ms, dropped 261,
|
||||
torn 0.
|
||||
|
||||
Reading the anomalies before quoting the headline numbers:
|
||||
|
||||
- All 261 dropped frames and the single 2.3 s worst-interval stall happened
|
||||
in the **first ~60 s** (startup/warmup); from t=93 s to the end — zero
|
||||
drops over ~8.5 minutes.
|
||||
- The steady-state late frames (~102 × late1.5x, ~43 × late2.5x after
|
||||
warmup ≈ 0.36 % / 0.15 %) track the `--loop` restarts of the 12 s test
|
||||
clip (~48 restarts, each a decoder reinit hiccup) — a test-clip artifact,
|
||||
not a pipeline property. A real long-form stream should be cleaner.
|
||||
- torn=0 across the whole run: the seqlock ring never produced a torn read.
|
||||
|
||||
Cadence verdict on this hardware: the producer keeps a clean source cadence
|
||||
(mpv's own pacing survives); the only jitter is display-grid quantization in
|
||||
the rAF presenter, bounded by one 120 Hz tick (~8.3 ms). A future refinement
|
||||
could reduce it with display-rate matching, but nothing here blocks the gate.
|
||||
|
||||
HDR clip generation for repro:
|
||||
|
||||
```bash
|
||||
ffmpeg -y -f lavfi -i "testsrc2=size=3840x2160:rate=25" -t 10 \
|
||||
-c:v hevc_videotoolbox -profile:v main10 -pix_fmt p010le -b:v 30M -tag:v hvc1 /tmp/spike-4k-hdr.mp4
|
||||
ffmpeg -y -i /tmp/spike-4k-hdr.mp4 -c:v copy \
|
||||
-bsf:v "hevc_metadata=colour_primaries=9:transfer_characteristics=16:matrix_coefficients=9" \
|
||||
/tmp/spike-4k-hdr10.mp4 # videotoolbox omits VUI color tags; inject HDR10 ones
|
||||
```
|
||||
|
||||
Reference worst-case budget from the analysis doc, for orientation:
|
||||
33 MB 4K memcpy est. 6–8 ms (measured ≈1.2 ms); end-to-end added latency
|
||||
est. 40–60 ms (measured ≈10 ms produce→uploaded, before compositor).
|
||||
|
||||
## Intel Mac — SKIPPED by decision (2026-07-10)
|
||||
|
||||
Owner decision: the frame-copy engine targets Apple Silicon (M1+) only on
|
||||
macOS; support detection must gate on arm64. Rationale: Intel Macs able to
|
||||
run the app at all (Electron 41 ⇒ macOS 10.15+) are a shrinking 2015–2020
|
||||
cohort and keep the existing docked/external/web player paths; the only
|
||||
Intel device sourced (iMac mid-2011, High Sierra) cannot run the app and
|
||||
would not have been representative. The macOS hardware gate is therefore
|
||||
closed by the M1 Pro numbers above; the remaining risk hardware is
|
||||
Windows/Linux.
|
||||
|
||||
## Windows mid-range laptop (iGPU) — PENDING
|
||||
|
||||
Blocked on the Windows helper port (WGL or D3D11 readback path).
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the spike and run the full RESULTS.md scenario suite automatically,
|
||||
# writing all metrics into one results-<host>-<date>.txt file to send back.
|
||||
#
|
||||
# ./collect-results.sh # quick suite (~4 min, 30 s per scenario)
|
||||
# ./collect-results.sh --long # + 10-minute 4K60 HEVC long run
|
||||
#
|
||||
# Prereqs on the target machine: Xcode Command Line Tools and Homebrew mpv
|
||||
# (`brew install mpv`). Electron and media clips are bundled (or resolved
|
||||
# from the repo / generated with ffmpeg when running from a checkout).
|
||||
# Viewer windows will open and close on their own during the run.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
DURATION="${DURATION:-30}"
|
||||
LONG=0
|
||||
[[ "${1:-}" == "--long" ]] && LONG=1
|
||||
|
||||
if ! xcode-select -p >/dev/null 2>&1; then
|
||||
echo "Xcode Command Line Tools required: run 'xcode-select --install'" >&2
|
||||
exit 1
|
||||
fi
|
||||
MPV_PREFIX=""
|
||||
for prefix in "$(brew --prefix 2>/dev/null || true)" /opt/local; do
|
||||
if [[ -n "$prefix" ]] && ls "$prefix"/lib/libmpv*.dylib >/dev/null 2>&1; then
|
||||
MPV_PREFIX="$prefix"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$MPV_PREFIX" ]]; then
|
||||
echo "libmpv not found. Install it with Homebrew ('brew install mpv') or," >&2
|
||||
echo "on legacy macOS (e.g. High Sierra), MacPorts: 'sudo port install mpv +libmpv'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make
|
||||
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
for candidate in \
|
||||
"$DIR/electron/Electron.app/Contents/MacOS/Electron" \
|
||||
"$DIR/../../node_modules/.bin/electron" \
|
||||
"$DIR/../../../../../node_modules/.bin/electron"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
ELECTRON="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
echo "Electron not found; set ELECTRON=/path/to/electron" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ELECTRON" == "$DIR/electron/"* ]]; then
|
||||
# Bundled runtime may carry a quarantine attribute after transfer.
|
||||
xattr -cr "$DIR/electron/Electron.app" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Media clips: bundled under media/, otherwise generate with ffmpeg.
|
||||
mkdir -p media
|
||||
if [[ ! -f media/spike-4k-hevc.mp4 ]] && command -v ffmpeg >/dev/null; then
|
||||
echo "generating media/spike-4k-hevc.mp4 ..."
|
||||
ffmpeg -v error -y -f lavfi -i "testsrc2=size=3840x2160:rate=60" -t 12 \
|
||||
-c:v hevc_videotoolbox -b:v 25M -tag:v hvc1 -pix_fmt yuv420p \
|
||||
media/spike-4k-hevc.mp4
|
||||
fi
|
||||
if [[ ! -f media/spike-4k-hdr10.mp4 && -f media/spike-4k-hevc.mp4 ]] &&
|
||||
command -v ffmpeg >/dev/null; then
|
||||
echo "generating media/spike-4k-hdr10.mp4 ..."
|
||||
ffmpeg -v error -y -f lavfi -i "testsrc2=size=3840x2160:rate=25" -t 10 \
|
||||
-c:v hevc_videotoolbox -profile:v main10 -pix_fmt p010le -b:v 30M \
|
||||
-tag:v hvc1 /tmp/spike-hdr-base.mp4
|
||||
ffmpeg -v error -y -i /tmp/spike-hdr-base.mp4 -c:v copy \
|
||||
-bsf:v "hevc_metadata=colour_primaries=9:transfer_characteristics=16:matrix_coefficients=9" \
|
||||
media/spike-4k-hdr10.mp4
|
||||
fi
|
||||
|
||||
OUT="$DIR/results-$(hostname -s)-$(date +%Y%m%d-%H%M).txt"
|
||||
{
|
||||
echo "# mpv frame-copy spike results"
|
||||
echo "date: $(date)"
|
||||
echo "cpu: $(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)"
|
||||
echo "os: $(sw_vers -productName) $(sw_vers -productVersion)"
|
||||
system_profiler SPDisplaysDataType 2>/dev/null |
|
||||
grep -E "Chipset Model|Resolution|UI Looks like" | sed 's/^ *//' || true
|
||||
} > "$OUT"
|
||||
|
||||
run_scenario() { # name media size extra_helper_args duration
|
||||
local name="$1" media="$2" size="$3" extra="$4" dur="$5"
|
||||
echo "== $name (${dur}s) =="
|
||||
printf '\n## %s\n' "$name" >> "$OUT"
|
||||
local hlog vlog
|
||||
hlog="$(mktemp /tmp/spike-helper.XXXXXX)"
|
||||
vlog="$(mktemp /tmp/spike-viewer.XXXXXX)"
|
||||
# shellcheck disable=SC2086
|
||||
./build/mpv_helper "$media" --size "$size" --no-audio $extra \
|
||||
> "$hlog" 2>&1 &
|
||||
local hpid=$!
|
||||
disown "$hpid"
|
||||
sleep 2
|
||||
SPIKE_SHM=/mpv-frame-spike "$ELECTRON" "$DIR/viewer/main.js" \
|
||||
> "$vlog" 2>&1 &
|
||||
local vpid=$!
|
||||
disown "$vpid"
|
||||
sleep "$dur"
|
||||
kill "$vpid" 2>/dev/null || true
|
||||
kill -INT "$hpid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$hpid" "$vpid" 2>/dev/null || true
|
||||
grep -E "PIXELPROBE|STATS|LONGRUN" "$vlog" |
|
||||
sed 's/^\[viewer\] //' | tail -24 >> "$OUT"
|
||||
grep -E "^\[helper\] (GL renderer|fps=)" "$hlog" | tail -6 >> "$OUT"
|
||||
rm -f "$hlog" "$vlog"
|
||||
}
|
||||
|
||||
run_scenario "1080p60 testsrc2 sw" \
|
||||
'av://lavfi:testsrc2=size=1920x1080:rate=60' 1920x1080 "" "$DURATION"
|
||||
run_scenario "4K60 testsrc2 sw" \
|
||||
'av://lavfi:testsrc2=size=3840x2160:rate=60' 3840x2160 "" "$DURATION"
|
||||
run_scenario "1080p50 cadence" \
|
||||
'av://lavfi:testsrc2=size=1920x1080:rate=50' 1920x1080 "" "$DURATION"
|
||||
if [[ -f media/spike-4k-hevc.mp4 ]]; then
|
||||
run_scenario "4K60 HEVC hwdec=videotoolbox" \
|
||||
media/spike-4k-hevc.mp4 3840x2160 "--hwdec videotoolbox --loop" "$DURATION"
|
||||
run_scenario "4K60 HEVC -> 720p viewport" \
|
||||
media/spike-4k-hevc.mp4 1280x720 "--hwdec videotoolbox --loop" "$DURATION"
|
||||
else
|
||||
echo "WARN: media/spike-4k-hevc.mp4 missing, HEVC scenarios skipped" | tee -a "$OUT"
|
||||
fi
|
||||
if [[ -f media/spike-4k-hdr10.mp4 ]]; then
|
||||
run_scenario "4K25 HDR10 tonemap hwdec" \
|
||||
media/spike-4k-hdr10.mp4 3840x2160 "--hwdec videotoolbox --loop" "$DURATION"
|
||||
else
|
||||
echo "WARN: media/spike-4k-hdr10.mp4 missing, HDR scenario skipped" | tee -a "$OUT"
|
||||
fi
|
||||
if [[ "$LONG" == 1 && -f media/spike-4k-hevc.mp4 ]]; then
|
||||
run_scenario "LONGRUN 10min 4K60 HEVC hwdec" \
|
||||
media/spike-4k-hevc.mp4 3840x2160 "--hwdec videotoolbox --loop" 600
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Done. Send this file back:"
|
||||
echo " $OUT"
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Shared-memory frame ring protocol for the mpv frame-copy spike.
|
||||
*
|
||||
* Producer: helper/mpv_helper.cpp (C++) — renders mpv offscreen, writes
|
||||
* BGRA frames into a 3-slot ring inside POSIX shared memory.
|
||||
* Consumer: viewer/addon/shm_reader.c (C, N-API) — copies the newest
|
||||
* complete frame out for WebGL upload in an Electron renderer.
|
||||
*
|
||||
* This header must compile both as C11 and C++17; the atomic fields rely on
|
||||
* _Atomic uint64_t and std::atomic<uint64_t> having identical size/layout
|
||||
* (both are 8-byte lock-free on the targets this spike cares about).
|
||||
*
|
||||
* Slot protocol (classic triple buffer with per-slot seqlock):
|
||||
* writer: slot.seq = 0 -> memcpy frame + fill meta -> slot.seq = seq
|
||||
* -> header.latest_seq = seq (all stores release)
|
||||
* reader: seq = header.latest_seq (acquire); slot = slots[seq % SLOTS];
|
||||
* verify slot.seq == seq, copy, re-check slot.seq for tearing.
|
||||
* The writer always writes slot (seq % SLOTS) with a monotonically growing
|
||||
* seq, so the newest complete slot is never the one being overwritten next.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
typedef std::atomic<uint64_t> spike_atomic_u64;
|
||||
#else
|
||||
#include <stdatomic.h>
|
||||
typedef _Atomic uint64_t spike_atomic_u64;
|
||||
#endif
|
||||
|
||||
#define SPIKE_SHM_DEFAULT_NAME "/mpv-frame-spike"
|
||||
#define SPIKE_SHM_MAGIC 0x564d5053u /* 'SPMV' little-endian */
|
||||
#define SPIKE_SHM_VERSION 1u
|
||||
#define SPIKE_RING_SLOTS 3u
|
||||
#define SPIKE_DATA_ALIGN 4096u
|
||||
#define SPIKE_PTS_UNKNOWN UINT64_MAX
|
||||
|
||||
typedef struct {
|
||||
spike_atomic_u64 seq; /* 0 while the slot is being (re)written */
|
||||
uint64_t pts_us; /* mpv time-pos in µs, SPIKE_PTS_UNKNOWN if n/a */
|
||||
uint64_t produce_time_ns; /* CLOCK_MONOTONIC_RAW, set after copy is done */
|
||||
} SpikeSlot;
|
||||
|
||||
typedef struct {
|
||||
uint32_t magic; /* written last during init (with release fence) */
|
||||
uint32_t version;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t stride; /* bytes per row, tightly packed: width * 4 */
|
||||
uint32_t reserved;
|
||||
uint64_t frame_bytes; /* stride * height */
|
||||
uint64_t data_offset; /* from mapping base, SPIKE_DATA_ALIGN aligned */
|
||||
spike_atomic_u64 latest_seq; /* newest complete frame, 0 = none */
|
||||
spike_atomic_u64 producer_fps_milli; /* producer render fps * 1000 */
|
||||
spike_atomic_u64 heartbeat_ns; /* producer liveness */
|
||||
SpikeSlot slots[SPIKE_RING_SLOTS];
|
||||
} SpikeShmHeader;
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* mpv frame-copy spike — helper process (macOS).
|
||||
*
|
||||
* Owns libmpv in a standalone process: decodes + renders offscreen into a GL
|
||||
* FBO (headless CGL context, no window), reads frames back through a 3-deep
|
||||
* async PBO ring, and publishes BGRA frames into a POSIX shared-memory ring
|
||||
* (see common/spike_shm.h). Audio plays directly from this process.
|
||||
*
|
||||
* Prints one stats line per second: render ms, PBO map+copy ms (avg/p95),
|
||||
* effective fps. These are the producer-side numbers for the go/no-go gates.
|
||||
*/
|
||||
#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 <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <clocale>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "spike_shm.h"
|
||||
|
||||
namespace {
|
||||
|
||||
struct Args {
|
||||
std::string media;
|
||||
std::string shmName = SPIKE_SHM_DEFAULT_NAME;
|
||||
std::string hwdec = "auto";
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
bool audio = true;
|
||||
bool loop = false;
|
||||
};
|
||||
|
||||
uint64_t nowNs() { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); }
|
||||
|
||||
[[noreturn]] void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: mpv_helper <media> [--size WxH] [--shm /name] "
|
||||
"[--hwdec <mode>] [--no-audio] [--loop]\n");
|
||||
std::exit(2);
|
||||
}
|
||||
|
||||
Args parseArgs(int argc, char** argv) {
|
||||
Args args;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const std::string a = argv[i];
|
||||
auto next = [&]() -> std::string {
|
||||
if (i + 1 >= argc) usage();
|
||||
return argv[++i];
|
||||
};
|
||||
if (a == "--size") {
|
||||
const std::string v = next();
|
||||
if (std::sscanf(v.c_str(), "%dx%d", &args.width, &args.height) != 2)
|
||||
usage();
|
||||
} else if (a == "--shm") {
|
||||
args.shmName = next();
|
||||
} else if (a == "--hwdec") {
|
||||
args.hwdec = next();
|
||||
} else if (a == "--no-audio") {
|
||||
args.audio = false;
|
||||
} else if (a == "--loop") {
|
||||
args.loop = true;
|
||||
} else if (!a.empty() && a[0] == '-') {
|
||||
usage();
|
||||
} else if (args.media.empty()) {
|
||||
args.media = a;
|
||||
} else {
|
||||
usage();
|
||||
}
|
||||
}
|
||||
if (args.media.empty() || args.width < 16 || args.height < 16) usage();
|
||||
return args;
|
||||
}
|
||||
|
||||
/* ---- shared memory ----------------------------------------------------- */
|
||||
|
||||
struct Shm {
|
||||
SpikeShmHeader* hdr = nullptr;
|
||||
uint8_t* base = nullptr;
|
||||
size_t size = 0;
|
||||
std::string name;
|
||||
|
||||
uint8_t* slotData(uint64_t seq) const {
|
||||
return base + hdr->data_offset +
|
||||
(seq % SPIKE_RING_SLOTS) * hdr->frame_bytes;
|
||||
}
|
||||
};
|
||||
|
||||
bool shmCreate(Shm& shm, const std::string& name, int width, int height) {
|
||||
shm_unlink(name.c_str()); /* remove a stale ring from a previous run */
|
||||
const int fd = shm_open(name.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
|
||||
if (fd < 0) {
|
||||
std::perror("shm_open");
|
||||
return false;
|
||||
}
|
||||
const uint64_t frameBytes = (uint64_t)width * 4u * (uint64_t)height;
|
||||
const uint64_t dataOffset =
|
||||
(sizeof(SpikeShmHeader) + SPIKE_DATA_ALIGN - 1) & ~(uint64_t)(SPIKE_DATA_ALIGN - 1);
|
||||
const size_t total = (size_t)(dataOffset + SPIKE_RING_SLOTS * frameBytes);
|
||||
if (ftruncate(fd, (off_t)total) != 0) {
|
||||
std::perror("ftruncate");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
void* base = mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) {
|
||||
std::perror("mmap");
|
||||
return false;
|
||||
}
|
||||
std::memset(base, 0, sizeof(SpikeShmHeader));
|
||||
auto* hdr = static_cast<SpikeShmHeader*>(base);
|
||||
hdr->version = SPIKE_SHM_VERSION;
|
||||
hdr->width = (uint32_t)width;
|
||||
hdr->height = (uint32_t)height;
|
||||
hdr->stride = (uint32_t)width * 4u;
|
||||
hdr->frame_bytes = frameBytes;
|
||||
hdr->data_offset = dataOffset;
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
hdr->magic = SPIKE_SHM_MAGIC; /* readers treat magic as the ready flag */
|
||||
|
||||
shm.hdr = hdr;
|
||||
shm.base = static_cast<uint8_t*>(base);
|
||||
shm.size = total;
|
||||
shm.name = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- stats -------------------------------------------------------------- */
|
||||
|
||||
struct PhaseStats {
|
||||
std::vector<double> samples;
|
||||
void add(double ms) { samples.push_back(ms); }
|
||||
double avg() const {
|
||||
if (samples.empty()) return 0;
|
||||
double s = 0;
|
||||
for (double v : samples) s += v;
|
||||
return s / (double)samples.size();
|
||||
}
|
||||
double p95() {
|
||||
if (samples.empty()) return 0;
|
||||
std::sort(samples.begin(), samples.end());
|
||||
return samples[(size_t)((double)(samples.size() - 1) * 0.95)];
|
||||
}
|
||||
void reset() { samples.clear(); }
|
||||
};
|
||||
|
||||
/* ---- render loop -------------------------------------------------------- */
|
||||
|
||||
void* g_glDylib = nullptr;
|
||||
|
||||
void* getProcAddress(void*, const char* name) {
|
||||
return dlsym(g_glDylib, name);
|
||||
}
|
||||
|
||||
struct RenderShared {
|
||||
mpv_handle* mpv = nullptr;
|
||||
Shm* shm = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
CGLContextObj cgl = nullptr;
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool updatePending = false;
|
||||
bool stop = false;
|
||||
bool ready = false;
|
||||
bool failed = false;
|
||||
};
|
||||
|
||||
void onMpvRenderUpdate(void* ctx) {
|
||||
auto* shared = static_cast<RenderShared*>(ctx);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
shared->updatePending = true;
|
||||
}
|
||||
shared->cv.notify_all();
|
||||
}
|
||||
|
||||
void renderThreadMain(RenderShared* shared) {
|
||||
CGLSetCurrentContext(shared->cgl);
|
||||
|
||||
const int w = shared->width;
|
||||
const int h = shared->height;
|
||||
const GLsizeiptr frameBytes = (GLsizeiptr)w * 4 * h;
|
||||
|
||||
GLuint texture = 0, fbo = 0;
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 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);
|
||||
|
||||
GLuint pbos[SPIKE_RING_SLOTS] = {0};
|
||||
glGenBuffers(SPIKE_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);
|
||||
|
||||
const bool fboOk =
|
||||
glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
|
||||
|
||||
mpv_opengl_init_params glInit = {getProcAddress, nullptr};
|
||||
mpv_render_param createParams[] = {
|
||||
{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},
|
||||
};
|
||||
mpv_render_context* rc = nullptr;
|
||||
const int createResult =
|
||||
fboOk ? mpv_render_context_create(&rc, shared->mpv, createParams) : -1;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
shared->ready = true;
|
||||
shared->failed = createResult < 0;
|
||||
}
|
||||
shared->cv.notify_all();
|
||||
if (createResult < 0) {
|
||||
std::fprintf(stderr, "[helper] render context init failed (%s)\n",
|
||||
fboOk ? mpv_error_string(createResult) : "FBO incomplete");
|
||||
return;
|
||||
}
|
||||
std::fprintf(stderr, "[helper] GL renderer: %s\n",
|
||||
(const char*)glGetString(GL_RENDERER));
|
||||
mpv_render_context_set_update_callback(rc, onMpvRenderUpdate, shared);
|
||||
|
||||
SpikeShmHeader* hdr = shared->shm->hdr;
|
||||
uint64_t nextSeq = 1;
|
||||
int pendingPbo = -1; /* PBO with an in-flight readback from last frame */
|
||||
uint64_t pendingPtsUs = SPIKE_PTS_UNKNOWN;
|
||||
int cursor = 0;
|
||||
|
||||
PhaseStats renderStats, copyStats;
|
||||
uint64_t framesThisSecond = 0;
|
||||
uint64_t statsWindowStart = nowNs();
|
||||
|
||||
auto publishPending = [&]() {
|
||||
if (pendingPbo < 0) return;
|
||||
const uint64_t t0 = nowNs();
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos[pendingPbo]);
|
||||
const void* mapped =
|
||||
glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, frameBytes, GL_MAP_READ_BIT);
|
||||
if (mapped) {
|
||||
const uint64_t seq = nextSeq++;
|
||||
SpikeSlot& slot = hdr->slots[seq % SPIKE_RING_SLOTS];
|
||||
slot.seq.store(0, std::memory_order_release);
|
||||
std::memcpy(shared->shm->slotData(seq), mapped,
|
||||
(size_t)frameBytes);
|
||||
slot.pts_us = pendingPtsUs;
|
||||
slot.produce_time_ns = nowNs();
|
||||
slot.seq.store(seq, std::memory_order_release);
|
||||
hdr->latest_seq.store(seq, std::memory_order_release);
|
||||
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
|
||||
copyStats.add((double)(nowNs() - t0) / 1e6);
|
||||
framesThisSecond++;
|
||||
}
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
pendingPbo = -1;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(shared->mutex);
|
||||
shared->cv.wait_for(lock, std::chrono::milliseconds(100), [&] {
|
||||
return shared->updatePending || shared->stop;
|
||||
});
|
||||
if (shared->stop) break;
|
||||
shared->updatePending = false;
|
||||
}
|
||||
|
||||
const uint64_t flags = mpv_render_context_update(rc);
|
||||
if (flags & MPV_RENDER_UPDATE_FRAME) {
|
||||
const uint64_t t0 = nowNs();
|
||||
mpv_opengl_fbo mpvFbo = {(int)fbo, w, h, 0};
|
||||
int flipY = 1; /* top-down rows so the consumer can upload as-is */
|
||||
mpv_render_param renderParams[] = {
|
||||
{MPV_RENDER_PARAM_OPENGL_FBO, &mpvFbo},
|
||||
{MPV_RENDER_PARAM_FLIP_Y, &flipY},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
mpv_render_context_render(rc, renderParams);
|
||||
renderStats.add((double)(nowNs() - t0) / 1e6);
|
||||
|
||||
/* Kick off the async readback for this frame ... */
|
||||
double pts = 0;
|
||||
pendingPtsUs =
|
||||
mpv_get_property(shared->mpv, "time-pos", MPV_FORMAT_DOUBLE,
|
||||
&pts) >= 0
|
||||
? (uint64_t)(pts * 1e6)
|
||||
: SPIKE_PTS_UNKNOWN;
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
|
||||
/* Publish the previous frame first so this readback stays async. */
|
||||
publishPending();
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos[cursor]);
|
||||
glReadPixels(0, 0, w, h, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
|
||||
nullptr);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
pendingPbo = cursor;
|
||||
cursor = (cursor + 1) % SPIKE_RING_SLOTS;
|
||||
mpv_render_context_report_swap(rc);
|
||||
} else {
|
||||
/* No new video frame; still flush a leftover readback so pause
|
||||
* doesn't strand the last frame inside a PBO. */
|
||||
publishPending();
|
||||
}
|
||||
|
||||
const uint64_t now = nowNs();
|
||||
hdr->heartbeat_ns.store(now, std::memory_order_relaxed);
|
||||
if (now - statsWindowStart >= 1000000000ull) {
|
||||
const double seconds = (double)(now - statsWindowStart) / 1e9;
|
||||
const double fps = (double)framesThisSecond / seconds;
|
||||
hdr->producer_fps_milli.store((uint64_t)(fps * 1000.0),
|
||||
std::memory_order_relaxed);
|
||||
std::fprintf(stderr,
|
||||
"[helper] fps=%5.1f | render ms avg=%5.2f p95=%5.2f | "
|
||||
"map+copy ms avg=%5.2f p95=%5.2f\n",
|
||||
fps, renderStats.avg(), renderStats.p95(),
|
||||
copyStats.avg(), copyStats.p95());
|
||||
renderStats.reset();
|
||||
copyStats.reset();
|
||||
framesThisSecond = 0;
|
||||
statsWindowStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
mpv_render_context_set_update_callback(rc, nullptr, nullptr);
|
||||
mpv_render_context_free(rc);
|
||||
glDeleteBuffers(SPIKE_RING_SLOTS, pbos);
|
||||
glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteTextures(1, &texture);
|
||||
CGLSetCurrentContext(nullptr);
|
||||
}
|
||||
|
||||
/* ---- main / mpv event loop ---------------------------------------------- */
|
||||
|
||||
std::atomic<bool> g_quit{false};
|
||||
void onSignal(int) { g_quit.store(true); }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::setlocale(LC_NUMERIC, "C");
|
||||
const Args args = parseArgs(argc, argv);
|
||||
|
||||
g_glDylib = dlopen(
|
||||
"/System/Library/Frameworks/OpenGL.framework/Versions/Current/OpenGL",
|
||||
RTLD_LAZY | RTLD_LOCAL);
|
||||
if (!g_glDylib) {
|
||||
std::fprintf(stderr, "[helper] failed to dlopen OpenGL framework\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Shm shm;
|
||||
if (!shmCreate(shm, args.shmName, args.width, args.height)) return 1;
|
||||
|
||||
CGLPixelFormatAttribute pfAttrs[] = {
|
||||
kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core,
|
||||
kCGLPFAAccelerated,
|
||||
kCGLPFAColorSize, (CGLPixelFormatAttribute)24,
|
||||
kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8,
|
||||
(CGLPixelFormatAttribute)0,
|
||||
};
|
||||
CGLPixelFormatObj pixelFormat = nullptr;
|
||||
GLint pixelFormatCount = 0;
|
||||
CGLContextObj cgl = nullptr;
|
||||
if (CGLChoosePixelFormat(pfAttrs, &pixelFormat, &pixelFormatCount) !=
|
||||
kCGLNoError ||
|
||||
!pixelFormat ||
|
||||
CGLCreateContext(pixelFormat, nullptr, &cgl) != kCGLNoError) {
|
||||
std::fprintf(stderr, "[helper] failed to create headless CGL context\n");
|
||||
return 1;
|
||||
}
|
||||
CGLDestroyPixelFormat(pixelFormat);
|
||||
|
||||
mpv_handle* mpv = mpv_create();
|
||||
if (!mpv) {
|
||||
std::fprintf(stderr, "[helper] mpv_create failed\n");
|
||||
return 1;
|
||||
}
|
||||
mpv_set_option_string(mpv, "vo", "libmpv");
|
||||
mpv_set_option_string(mpv, "hwdec", args.hwdec.c_str());
|
||||
mpv_set_option_string(mpv, "keep-open", "yes");
|
||||
if (!args.audio) mpv_set_option_string(mpv, "aid", "no");
|
||||
if (args.loop) mpv_set_option_string(mpv, "loop-file", "inf");
|
||||
if (mpv_initialize(mpv) < 0) {
|
||||
std::fprintf(stderr, "[helper] mpv_initialize failed\n");
|
||||
return 1;
|
||||
}
|
||||
mpv_request_log_messages(mpv, "warn");
|
||||
|
||||
std::fprintf(stderr,
|
||||
"[helper] media=%s size=%dx%d shm=%s hwdec=%s audio=%s\n",
|
||||
args.media.c_str(), args.width, args.height,
|
||||
args.shmName.c_str(), args.hwdec.c_str(),
|
||||
args.audio ? "on" : "off");
|
||||
|
||||
RenderShared shared;
|
||||
shared.mpv = mpv;
|
||||
shared.shm = &shm;
|
||||
shared.width = args.width;
|
||||
shared.height = args.height;
|
||||
shared.cgl = cgl;
|
||||
|
||||
std::thread renderThread(renderThreadMain, &shared);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(shared.mutex);
|
||||
shared.cv.wait(lock, [&] { return shared.ready; });
|
||||
if (shared.failed) {
|
||||
lock.unlock();
|
||||
renderThread.join();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGINT, onSignal);
|
||||
signal(SIGTERM, onSignal);
|
||||
|
||||
const char* loadCmd[] = {"loadfile", args.media.c_str(), nullptr};
|
||||
mpv_command(mpv, loadCmd);
|
||||
|
||||
while (!g_quit.load()) {
|
||||
mpv_event* event = mpv_wait_event(mpv, 0.25);
|
||||
if (event->event_id == MPV_EVENT_NONE) continue;
|
||||
if (event->event_id == MPV_EVENT_SHUTDOWN) break;
|
||||
if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
|
||||
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
||||
std::fprintf(stderr, "[mpv/%s] %s", msg->prefix, msg->text);
|
||||
} else if (event->event_id == MPV_EVENT_END_FILE) {
|
||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||
std::fprintf(stderr, "[helper] end-file reason=%d error=%s\n",
|
||||
end->reason,
|
||||
end->error < 0 ? mpv_error_string(end->error) : "none");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared.mutex);
|
||||
shared.stop = true;
|
||||
}
|
||||
shared.cv.notify_all();
|
||||
renderThread.join();
|
||||
mpv_terminate_destroy(mpv);
|
||||
CGLDestroyContext(cgl);
|
||||
munmap(shm.base, shm.size);
|
||||
shm_unlink(shm.name.c_str());
|
||||
std::fprintf(stderr, "[helper] clean exit\n");
|
||||
return 0;
|
||||
}
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble a self-contained tarball of the spike for a machine without
|
||||
# Node/pnpm (e.g. the Intel Mac measurement run). The bundle carries the
|
||||
# spike sources, vendored N-API headers, pre-generated test clips, and an
|
||||
# official Electron dist download; the target machine only needs Xcode
|
||||
# Command Line Tools and `brew install mpv`.
|
||||
#
|
||||
# ./make-bundle.sh [x64|arm64] # Electron arch of the TARGET machine
|
||||
#
|
||||
# For legacy macOS targets override the Electron version — Electron 27+
|
||||
# requires macOS 10.15, Electron 26.6.10 is the last for 10.13/10.14:
|
||||
# ELECTRON_VERSION=26.6.10 BUNDLE_SUFFIX=highsierra-x64 ./make-bundle.sh x64
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
ARCH="${1:-x64}"
|
||||
ELECTRON_VERSION="${ELECTRON_VERSION:-41.7.2}"
|
||||
BUNDLE_SUFFIX="${BUNDLE_SUFFIX:-$ARCH}"
|
||||
BUNDLE_ROOT="build/bundle/mpv-frame-copy-spike"
|
||||
TARBALL="build/mpv-frame-copy-spike-macos-$BUNDLE_SUFFIX.tar.gz"
|
||||
|
||||
rm -rf build/bundle
|
||||
mkdir -p "$BUNDLE_ROOT"
|
||||
|
||||
cp -R common helper viewer Makefile run.sh collect-results.sh \
|
||||
README.md RESULTS.md DESIGN.md "$BUNDLE_ROOT/"
|
||||
|
||||
# N-API headers so the addon builds without any Node installation.
|
||||
NODE_INC="$(node -p "require('path').join(process.execPath, '..', '..', 'include', 'node')" 2>/dev/null || true)"
|
||||
if [[ -z "$NODE_INC" || ! -f "$NODE_INC/node_api.h" ]]; then
|
||||
echo "need a Node >= 18 on PATH to source N-API headers" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$BUNDLE_ROOT/vendor/node-headers"
|
||||
cp "$NODE_INC"/node_api.h "$NODE_INC"/node_api_types.h \
|
||||
"$NODE_INC"/js_native_api.h "$NODE_INC"/js_native_api_types.h \
|
||||
"$BUNDLE_ROOT/vendor/node-headers/"
|
||||
|
||||
# Pre-generate the test clips so the target needs no ffmpeg.
|
||||
mkdir -p media
|
||||
if [[ ! -f media/spike-4k-hevc.mp4 ]]; then
|
||||
echo "generating media/spike-4k-hevc.mp4 ..."
|
||||
ffmpeg -v error -y -f lavfi -i "testsrc2=size=3840x2160:rate=60" -t 12 \
|
||||
-c:v hevc_videotoolbox -b:v 25M -tag:v hvc1 -pix_fmt yuv420p \
|
||||
media/spike-4k-hevc.mp4
|
||||
fi
|
||||
if [[ ! -f media/spike-4k-hdr10.mp4 ]]; then
|
||||
echo "generating media/spike-4k-hdr10.mp4 ..."
|
||||
ffmpeg -v error -y -f lavfi -i "testsrc2=size=3840x2160:rate=25" -t 10 \
|
||||
-c:v hevc_videotoolbox -profile:v main10 -pix_fmt p010le -b:v 30M \
|
||||
-tag:v hvc1 /tmp/spike-hdr-base.mp4
|
||||
ffmpeg -v error -y -i /tmp/spike-hdr-base.mp4 -c:v copy \
|
||||
-bsf:v "hevc_metadata=colour_primaries=9:transfer_characteristics=16:matrix_coefficients=9" \
|
||||
media/spike-4k-hdr10.mp4
|
||||
fi
|
||||
mkdir -p "$BUNDLE_ROOT/media"
|
||||
cp media/spike-4k-hevc.mp4 media/spike-4k-hdr10.mp4 "$BUNDLE_ROOT/media/"
|
||||
|
||||
# Official Electron dist for the target arch (no npm involved).
|
||||
ELECTRON_ZIP="build/electron-v$ELECTRON_VERSION-darwin-$ARCH.zip"
|
||||
if [[ ! -f "$ELECTRON_ZIP" ]]; then
|
||||
echo "downloading electron v$ELECTRON_VERSION darwin-$ARCH ..."
|
||||
curl -fL --retry 3 -o "$ELECTRON_ZIP" \
|
||||
"https://github.com/electron/electron/releases/download/v$ELECTRON_VERSION/electron-v$ELECTRON_VERSION-darwin-$ARCH.zip"
|
||||
fi
|
||||
mkdir -p "$BUNDLE_ROOT/electron"
|
||||
unzip -q "$ELECTRON_ZIP" -d "$BUNDLE_ROOT/electron"
|
||||
|
||||
cat > "$BUNDLE_ROOT/START-HERE.md" <<'EOF'
|
||||
# mpv frame-copy spike — self-contained measurement bundle
|
||||
|
||||
Target-machine prerequisites (no Node/pnpm needed):
|
||||
|
||||
1. Xcode Command Line Tools: `xcode-select --install`
|
||||
2. libmpv, either via:
|
||||
- Homebrew (macOS 13+): `brew install mpv`
|
||||
- MacPorts (legacy macOS, e.g. High Sierra — install the MacPorts pkg
|
||||
for your macOS from macports.org first):
|
||||
`sudo port install mpv +libmpv` (compiles from source; can take
|
||||
an hour or more on old hardware)
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
./collect-results.sh # ~4 min, all scenarios
|
||||
./collect-results.sh --long # + 10-minute 4K60 long run (recommended)
|
||||
```
|
||||
|
||||
Viewer windows open and close by themselves during the run. At the end the
|
||||
script prints the path of a `results-<host>-<date>.txt` file — send that
|
||||
file back. Detailed docs: README.md; baseline numbers: RESULTS.md.
|
||||
EOF
|
||||
|
||||
tar -czf "$TARBALL" -C build/bundle mpv-frame-copy-spike
|
||||
echo
|
||||
echo "bundle ready: $DIR/$TARBALL"
|
||||
du -h "$TARBALL" | cut -f1
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and run the full spike: helper (producer) + Electron viewer (consumer).
|
||||
#
|
||||
# ./run.sh <media-url-or-path> [WxH]
|
||||
# ./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60' 1920x1080
|
||||
#
|
||||
# Env overrides: ELECTRON (viewer binary), SPIKE_SHM (ring name),
|
||||
# HELPER_ARGS (extra helper flags, e.g. "--hwdec videotoolbox-copy --loop").
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MEDIA="${1:?usage: run.sh <media-url-or-path> [WxH]}"
|
||||
SIZE="${2:-1920x1080}"
|
||||
SHM="${SPIKE_SHM:-/mpv-frame-spike}"
|
||||
|
||||
make -C "$DIR"
|
||||
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
for candidate in \
|
||||
"$DIR/../../node_modules/.bin/electron" \
|
||||
"$DIR/../../../../../node_modules/.bin/electron"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
ELECTRON="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
echo "electron binary not found; set ELECTRON=/path/to/electron" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
"$DIR/build/mpv_helper" "$MEDIA" --size "$SIZE" --shm "$SHM" ${HELPER_ARGS:-} &
|
||||
HELPER_PID=$!
|
||||
trap 'kill "$HELPER_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
SPIKE_SHM="$SHM" "$ELECTRON" "$DIR/viewer/main.js"
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* mpv frame-copy spike — shared-memory reader addon (plain C N-API).
|
||||
*
|
||||
* Maps the ring created by helper/mpv_helper.cpp and copies the newest
|
||||
* complete frame into a caller-provided ArrayBuffer. A memcpy is mandatory:
|
||||
* Electron's V8 memory cage forbids external ArrayBuffers over foreign
|
||||
* memory (napi_create_external_arraybuffer aborts), so zero-copy into JS is
|
||||
* not possible — this copy is one of the 2-3 copies the architecture doc
|
||||
* budgets for.
|
||||
*
|
||||
* Built directly with clang (-undefined dynamic_lookup); N-API is ABI-stable
|
||||
* so the same .node loads in Node and Electron.
|
||||
*/
|
||||
#define NAPI_VERSION 8
|
||||
#include <node_api.h>
|
||||
|
||||
#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 "spike_shm.h"
|
||||
|
||||
static SpikeShmHeader* g_hdr = 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 napi_value throw_error(napi_env env, const char* msg) {
|
||||
napi_throw_error(env, NULL, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void set_named_double(napi_env env, napi_value obj, const char* key,
|
||||
double value) {
|
||||
napi_value v;
|
||||
napi_create_double(env, value, &v);
|
||||
napi_set_named_property(env, obj, key, v);
|
||||
}
|
||||
|
||||
/* open(name) -> { width, height, stride, frameBytes } */
|
||||
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 a shm name");
|
||||
|
||||
char name[256];
|
||||
size_t len = 0;
|
||||
if (napi_get_value_string_utf8(env, argv[0], name, sizeof(name), &len) !=
|
||||
napi_ok)
|
||||
return throw_error(env, "shm name must be a string");
|
||||
|
||||
if (g_base) {
|
||||
munmap(g_base, g_size);
|
||||
g_base = NULL;
|
||||
g_hdr = NULL;
|
||||
}
|
||||
|
||||
const int fd = shm_open(name, O_RDONLY, 0);
|
||||
if (fd < 0) return throw_error(env, "shm_open failed (helper not running?)");
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || st.st_size < (off_t)sizeof(SpikeShmHeader)) {
|
||||
close(fd);
|
||||
return throw_error(env, "shm segment too small");
|
||||
}
|
||||
void* base = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) return throw_error(env, "mmap failed");
|
||||
|
||||
SpikeShmHeader* hdr = (SpikeShmHeader*)base;
|
||||
if (hdr->magic != SPIKE_SHM_MAGIC || hdr->version != SPIKE_SHM_VERSION) {
|
||||
munmap(base, (size_t)st.st_size);
|
||||
return throw_error(env, "shm ring not initialized yet");
|
||||
}
|
||||
|
||||
g_base = (uint8_t*)base;
|
||||
g_size = (size_t)st.st_size;
|
||||
g_hdr = hdr;
|
||||
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
set_named_double(env, result, "width", hdr->width);
|
||||
set_named_double(env, result, "height", hdr->height);
|
||||
set_named_double(env, result, "stride", hdr->stride);
|
||||
set_named_double(env, result, "frameBytes", (double)hdr->frame_bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* latestSeq() -> number (0 = no frame yet) */
|
||||
static napi_value LatestSeq(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
const uint64_t seq =
|
||||
g_hdr ? atomic_load_explicit(&g_hdr->latest_seq, memory_order_acquire)
|
||||
: 0;
|
||||
napi_create_double(env, (double)seq, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* copyLatest(arrayBuffer) -> { seq, ptsSec, ageMs, copyMs, 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_hdr) return throw_error(env, "call open() first");
|
||||
if (argc < 1) return throw_error(env, "copyLatest(buffer) needs a buffer");
|
||||
|
||||
void* dst = NULL;
|
||||
size_t dstLen = 0;
|
||||
if (napi_get_arraybuffer_info(env, argv[0], &dst, &dstLen) != napi_ok)
|
||||
return throw_error(env, "argument must be an ArrayBuffer");
|
||||
if (dstLen < g_hdr->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_hdr->latest_seq, memory_order_acquire);
|
||||
if (seq == 0) return null_value;
|
||||
|
||||
SpikeSlot* slot = &g_hdr->slots[seq % SPIKE_RING_SLOTS];
|
||||
if (atomic_load_explicit(&slot->seq, memory_order_acquire) != seq)
|
||||
return null_value; /* writer racing this slot; try next tick */
|
||||
|
||||
const uint8_t* src =
|
||||
g_base + g_hdr->data_offset + (seq % SPIKE_RING_SLOTS) * g_hdr->frame_bytes;
|
||||
const uint64_t t0 = now_ns();
|
||||
memcpy(dst, src, (size_t)g_hdr->frame_bytes);
|
||||
const uint64_t t1 = now_ns();
|
||||
|
||||
const int torn =
|
||||
atomic_load_explicit(&slot->seq, memory_order_acquire) != seq;
|
||||
const uint64_t pts_us = slot->pts_us;
|
||||
const double age_ms = (double)(t1 - slot->produce_time_ns) / 1e6;
|
||||
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
set_named_double(env, result, "seq", (double)seq);
|
||||
set_named_double(env, result, "ptsSec",
|
||||
pts_us == SPIKE_PTS_UNKNOWN ? -1.0 : (double)pts_us / 1e6);
|
||||
set_named_double(env, result, "ageMs", age_ms);
|
||||
set_named_double(env, result, "copyMs", (double)(t1 - t0) / 1e6);
|
||||
/* producer timestamp on the shared monotonic clock, for pacing stats */
|
||||
set_named_double(env, result, "produceMs",
|
||||
(double)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;
|
||||
}
|
||||
|
||||
/* producerFps() -> number */
|
||||
static napi_value ProducerFps(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
const double fps =
|
||||
g_hdr ? (double)atomic_load_explicit(&g_hdr->producer_fps_milli,
|
||||
memory_order_relaxed) /
|
||||
1000.0
|
||||
: 0;
|
||||
napi_create_double(env, fps, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* producerAliveMs() -> ms since last producer heartbeat (-1 if unknown) */
|
||||
static napi_value ProducerAliveMs(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
double ms = -1;
|
||||
if (g_hdr) {
|
||||
const uint64_t hb =
|
||||
atomic_load_explicit(&g_hdr->heartbeat_ns, memory_order_relaxed);
|
||||
if (hb) ms = (double)(now_ns() - hb) / 1e6;
|
||||
}
|
||||
napi_create_double(env, ms, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* nowMs() -> CLOCK_MONOTONIC_RAW in ms (same clock as helper timestamps) */
|
||||
static napi_value NowMs(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
napi_create_double(env, (double)now_ns() / 1e6, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
static napi_value Init(napi_env env, napi_value exports) {
|
||||
const struct {
|
||||
const char* name;
|
||||
napi_callback fn;
|
||||
} fns[] = {
|
||||
{"open", Open}, {"latestSeq", LatestSeq},
|
||||
{"copyLatest", CopyLatest}, {"producerFps", ProducerFps},
|
||||
{"producerAliveMs", ProducerAliveMs}, {"nowMs", NowMs},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(fns) / sizeof(fns[0]); i++) {
|
||||
napi_value fn;
|
||||
napi_create_function(env, fns[i].name, NAPI_AUTO_LENGTH, fns[i].fn,
|
||||
NULL, &fn);
|
||||
napi_set_named_property(env, exports, fns[i].name, fn);
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
NAPI_MODULE(shm_reader, Init)
|
||||
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>mpv frame-copy spike viewer</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #101014;
|
||||
color: #e8e8ee;
|
||||
font: 13px/1.5 ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
#stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
#hud {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(10, 10, 14, 0.78);
|
||||
border: 1px solid #2a2a33;
|
||||
border-radius: 6px;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div id="stage"><canvas id="video"></canvas></div>
|
||||
<div id="hud">waiting for helper…</div>
|
||||
<script>
|
||||
const path = require('path');
|
||||
const shm = require(path.join(__dirname, '..', 'build', 'shm_reader.node'));
|
||||
const shmName =
|
||||
new URLSearchParams(location.search).get('shm') || '/mpv-frame-spike';
|
||||
const hud = document.getElementById('hud');
|
||||
|
||||
function percentile(samples, p) {
|
||||
if (!samples.length) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return sorted[Math.floor((sorted.length - 1) * p)];
|
||||
}
|
||||
const avg = (samples) =>
|
||||
samples.length
|
||||
? samples.reduce((sum, v) => sum + v, 0) / samples.length
|
||||
: 0;
|
||||
function stddev(samples) {
|
||||
if (samples.length < 2) return 0;
|
||||
const mean = avg(samples);
|
||||
const variance =
|
||||
samples.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) /
|
||||
(samples.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
function median(samples) {
|
||||
if (!samples.length) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function start(info) {
|
||||
const width = info.width;
|
||||
const height = info.height;
|
||||
const canvas = document.getElementById('video');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: false,
|
||||
});
|
||||
const vertexSrc = `#version 300 es
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
|
||||
v_uv = vec2(corner.x, 1.0 - corner.y); // top-down rows in shm
|
||||
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`;
|
||||
const fragmentSrc = `#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); // shm frames are BGRA
|
||||
}`;
|
||||
const program = gl.createProgram();
|
||||
for (const [type, src] of [
|
||||
[gl.VERTEX_SHADER, vertexSrc],
|
||||
[gl.FRAGMENT_SHADER, fragmentSrc],
|
||||
]) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error('shader: ' + gl.getShaderInfoLog(shader));
|
||||
}
|
||||
gl.attachShader(program, shader);
|
||||
}
|
||||
gl.linkProgram(program);
|
||||
gl.useProgram(program);
|
||||
gl.createVertexArray && gl.bindVertexArray(gl.createVertexArray());
|
||||
|
||||
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);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, null,
|
||||
);
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
const frame = new Uint8Array(info.frameBytes);
|
||||
let pixelProbeDone = false;
|
||||
function probePixels() {
|
||||
// One-shot sanity check that frames carry a real image: sample a
|
||||
// grid and report channel spread (a black/uniform frame fails).
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const offset =
|
||||
Math.floor((i / 64) * (info.frameBytes / 4)) * 4;
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
const v = frame[offset + channel];
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
console.log(`PIXELPROBE min=${min} max=${max} spread=${max - min}`);
|
||||
}
|
||||
let lastSeq = 0;
|
||||
let drawTicks = 0;
|
||||
let newFrames = 0;
|
||||
let dropped = 0;
|
||||
let torn = 0;
|
||||
const copyMs = [];
|
||||
const uploadMs = [];
|
||||
const ageMs = [];
|
||||
// Pacing/judder instrumentation: intervals between consecutive
|
||||
// presented frames (viewer clock) and between produced frames
|
||||
// (helper clock via produceMs). The gate is variance, not avg fps.
|
||||
const presentIntervals = [];
|
||||
const producerIntervals = [];
|
||||
let lastPresentMs = 0;
|
||||
let lastProduceMs = 0;
|
||||
const longRun = {
|
||||
start: performance.now(),
|
||||
frames: 0,
|
||||
late15: 0, // present interval > 1.5x nominal (one missed beat)
|
||||
late25: 0, // present interval > 2.5x nominal (visible stutter)
|
||||
worstIntervalMs: 0,
|
||||
};
|
||||
let lastSummaryMs = performance.now();
|
||||
let windowStart = performance.now();
|
||||
|
||||
function tick() {
|
||||
requestAnimationFrame(tick);
|
||||
drawTicks++;
|
||||
|
||||
if (shm.latestSeq() > lastSeq) {
|
||||
const result = shm.copyLatest(frame.buffer);
|
||||
if (result) {
|
||||
const t0 = performance.now();
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, width, height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, frame,
|
||||
);
|
||||
const upload = performance.now() - t0;
|
||||
if (lastSeq > 0 && result.seq - lastSeq > 1) {
|
||||
dropped += result.seq - lastSeq - 1;
|
||||
}
|
||||
lastSeq = result.seq;
|
||||
newFrames++;
|
||||
if (!pixelProbeDone && result.seq > 60) {
|
||||
pixelProbeDone = true;
|
||||
probePixels();
|
||||
}
|
||||
copyMs.push(result.copyMs);
|
||||
uploadMs.push(upload);
|
||||
ageMs.push(result.ageMs + upload);
|
||||
if (result.torn) torn++;
|
||||
const tPresent = performance.now();
|
||||
if (lastPresentMs > 0) {
|
||||
presentIntervals.push(tPresent - lastPresentMs);
|
||||
}
|
||||
lastPresentMs = tPresent;
|
||||
if (lastProduceMs > 0) {
|
||||
producerIntervals.push(
|
||||
result.produceMs - lastProduceMs,
|
||||
);
|
||||
}
|
||||
lastProduceMs = result.produceMs;
|
||||
}
|
||||
}
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
|
||||
const now = performance.now();
|
||||
if (now - windowStart >= 2000) {
|
||||
const seconds = (now - windowStart) / 1000;
|
||||
// Nominal frame interval from the producer's own cadence —
|
||||
// robust against rAF quantization on high-Hz displays.
|
||||
const nominal =
|
||||
median(producerIntervals) ||
|
||||
(shm.producerFps() > 0 ? 1000 / shm.producerFps() : 0);
|
||||
let late15 = 0;
|
||||
let late25 = 0;
|
||||
let worst = 0;
|
||||
for (const interval of presentIntervals) {
|
||||
if (interval > worst) worst = interval;
|
||||
if (nominal > 0 && interval > nominal * 1.5) late15++;
|
||||
if (nominal > 0 && interval > nominal * 2.5) late25++;
|
||||
}
|
||||
longRun.frames += newFrames;
|
||||
longRun.late15 += late15;
|
||||
longRun.late25 += late25;
|
||||
longRun.worstIntervalMs = Math.max(
|
||||
longRun.worstIntervalMs,
|
||||
worst,
|
||||
);
|
||||
const line =
|
||||
`STATS draw=${(drawTicks / seconds).toFixed(1)}fps ` +
|
||||
`new=${(newFrames / seconds).toFixed(1)}fps ` +
|
||||
`producer=${shm.producerFps().toFixed(1)}fps | ` +
|
||||
`copy ms avg=${avg(copyMs).toFixed(2)} p95=${percentile(copyMs, 0.95).toFixed(2)} | ` +
|
||||
`upload ms avg=${avg(uploadMs).toFixed(2)} p95=${percentile(uploadMs, 0.95).toFixed(2)} | ` +
|
||||
`age ms avg=${avg(ageMs).toFixed(1)} p95=${percentile(ageMs, 0.95).toFixed(1)} | ` +
|
||||
`present iv ms sd=${stddev(presentIntervals).toFixed(2)} ` +
|
||||
`p99=${percentile(presentIntervals, 0.99).toFixed(1)} ` +
|
||||
`max=${worst.toFixed(1)} late1.5x=${late15} | ` +
|
||||
`src iv ms sd=${stddev(producerIntervals).toFixed(2)} ` +
|
||||
`p99=${percentile(producerIntervals, 0.99).toFixed(1)} | ` +
|
||||
`dropped=${dropped} torn=${torn}`;
|
||||
console.log(line);
|
||||
hud.textContent =
|
||||
`shm ${shmName} ${width}x${height}\n` +
|
||||
line.replace('STATS ', '').replaceAll(' | ', '\n');
|
||||
if (now - lastSummaryMs >= 30000) {
|
||||
lastSummaryMs = now;
|
||||
const totalSeconds = (now - longRun.start) / 1000;
|
||||
const latePct = (
|
||||
(100 * longRun.late15) / Math.max(1, longRun.frames)
|
||||
).toFixed(3);
|
||||
console.log(
|
||||
`LONGRUN t=${totalSeconds.toFixed(0)}s ` +
|
||||
`frames=${longRun.frames} ` +
|
||||
`avg=${(longRun.frames / totalSeconds).toFixed(2)}fps ` +
|
||||
`late1.5x=${longRun.late15} (${latePct}%) ` +
|
||||
`late2.5x=${longRun.late25} ` +
|
||||
`worstIv=${longRun.worstIntervalMs.toFixed(1)}ms ` +
|
||||
`dropped=${dropped} torn=${torn}`,
|
||||
);
|
||||
}
|
||||
drawTicks = 0;
|
||||
newFrames = 0;
|
||||
copyMs.length = 0;
|
||||
uploadMs.length = 0;
|
||||
ageMs.length = 0;
|
||||
presentIntervals.length = 0;
|
||||
producerIntervals.length = 0;
|
||||
windowStart = now;
|
||||
}
|
||||
}
|
||||
tick();
|
||||
}
|
||||
|
||||
(function waitForHelper() {
|
||||
let info = null;
|
||||
try {
|
||||
info = shm.open(shmName);
|
||||
} catch (error) {
|
||||
hud.textContent = `waiting for helper… (${error.message})`;
|
||||
}
|
||||
if (info) {
|
||||
console.log(
|
||||
`attached to ${shmName}: ${info.width}x${info.height}, ` +
|
||||
`${info.frameBytes} bytes/frame`,
|
||||
);
|
||||
start(info);
|
||||
} else {
|
||||
setTimeout(waitForHelper, 250);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* mpv frame-copy spike — Electron viewer harness.
|
||||
*
|
||||
* Deliberately minimal: nodeIntegration on, no preload, renderer console is
|
||||
* mirrored to stdout so an automated run can scrape the STATS lines.
|
||||
*/
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
const shmName = process.env.SPIKE_SHM || '/mpv-frame-spike';
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 980,
|
||||
backgroundColor: '#101014',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
win.webContents.on('console-message', (_event, _level, message) => {
|
||||
console.log(`[viewer] ${message}`);
|
||||
});
|
||||
win.loadFile(path.join(__dirname, 'index.html'), {
|
||||
query: { shm: shmName },
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
@@ -6,6 +6,9 @@ const {
|
||||
} = require('./embedded-mpv-packaging.cjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
preparePackagedFrameCopyArtifacts,
|
||||
} = require('./embedded-mpv-frame-copy-files.cjs');
|
||||
|
||||
function log(message) {
|
||||
console.log(` - ${message}`);
|
||||
@@ -19,7 +22,7 @@ function isTruthy(value) {
|
||||
);
|
||||
}
|
||||
|
||||
function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) {
|
||||
function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) {
|
||||
const sourceDir = path.join(
|
||||
projectDir,
|
||||
'dist',
|
||||
@@ -41,6 +44,8 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) {
|
||||
|
||||
fs.rmSync(destinationDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceDir, destinationDir, { recursive: true });
|
||||
|
||||
preparePackagedFrameCopyArtifacts(destinationDir, platform);
|
||||
}
|
||||
|
||||
function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) {
|
||||
@@ -85,7 +90,8 @@ async function afterPackHook(params) {
|
||||
} else {
|
||||
copyEmbeddedMpvNativeOutput(
|
||||
resourceDir,
|
||||
params.packager.projectDir ?? process.cwd()
|
||||
params.packager.projectDir ?? process.cwd(),
|
||||
params.electronPlatformName
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ const electronAfterPackSource = fs.readFileSync(
|
||||
join(currentDir, 'electron-after-pack.cjs'),
|
||||
'utf8'
|
||||
);
|
||||
const frameCopyFilesModulePath = join(
|
||||
currentDir,
|
||||
'embedded-mpv-frame-copy-files.cjs'
|
||||
);
|
||||
const embeddedMpvPackagingSource = fs.readFileSync(
|
||||
join(currentDir, 'embedded-mpv-packaging.cjs'),
|
||||
'utf8'
|
||||
@@ -327,6 +331,57 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
|
||||
);
|
||||
}
|
||||
|
||||
const darwinResourceDir = join(tempDir, 'darwin');
|
||||
const darwinNativeDir = join(
|
||||
darwinResourceDir,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
fs.mkdirSync(join(darwinNativeDir, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(join(darwinNativeDir, 'embedded_mpv.node'), '');
|
||||
fs.writeFileSync(
|
||||
join(darwinNativeDir, 'embedded-mpv-runtime.json'),
|
||||
JSON.stringify({ origin: 'vendored-lgpl' })
|
||||
);
|
||||
fs.writeFileSync(join(darwinNativeDir, 'lib', 'libmpv.2.dylib'), '');
|
||||
|
||||
// macOS packages that ship the addon must also ship the frame-copy
|
||||
// engine artifacts built by the same binding.gyp run.
|
||||
const missingFrameCopyErrors = validatePackagedEmbeddedMpv(
|
||||
darwinResourceDir,
|
||||
{ platform: 'darwin', required: true }
|
||||
);
|
||||
assert.ok(
|
||||
missingFrameCopyErrors.some((error) =>
|
||||
error.includes('iptvnator_mpv_helper')
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
missingFrameCopyErrors.some((error) =>
|
||||
error.includes('embedded_mpv_frame_reader.node')
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(join(darwinNativeDir, 'iptvnator_mpv_helper'), '');
|
||||
fs.writeFileSync(
|
||||
join(darwinNativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
''
|
||||
);
|
||||
// Host-agnostic assertion: on non-macOS hosts the validator also
|
||||
// reports that link validation needs a macOS host, so only the
|
||||
// frame-copy artifact requirement is asserted here.
|
||||
const remainingErrors = validatePackagedEmbeddedMpv(
|
||||
darwinResourceDir,
|
||||
{ platform: 'darwin', required: true }
|
||||
);
|
||||
assert.ok(
|
||||
!remainingErrors.some((error) =>
|
||||
error.includes('frame-copy artifact')
|
||||
),
|
||||
`unexpected frame-copy errors: ${remainingErrors.join('; ')}`
|
||||
);
|
||||
|
||||
const linuxResourceDir = join(tempDir, 'linux');
|
||||
const linuxNativeDir = join(
|
||||
linuxResourceDir,
|
||||
@@ -404,6 +459,55 @@ test('embedded MPV packaging helpers use a cross-platform module name', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('frame-copy packaging file operations enforce modes and remove stale artifacts', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(frameCopyFilesModulePath),
|
||||
'shared frame-copy packaging file helper must exist'
|
||||
);
|
||||
const {
|
||||
preparePackagedFrameCopyArtifacts,
|
||||
removeStaleFrameCopyArtifacts,
|
||||
} = require(frameCopyFilesModulePath);
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'impv-fc-files-'));
|
||||
|
||||
try {
|
||||
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
|
||||
const windowsHelperPath = join(
|
||||
tempDir,
|
||||
'iptvnator_mpv_helper.exe'
|
||||
);
|
||||
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
|
||||
fs.writeFileSync(helperPath, '#!/bin/sh\n');
|
||||
fs.chmodSync(helperPath, 0o644);
|
||||
fs.writeFileSync(windowsHelperPath, 'exe');
|
||||
fs.writeFileSync(readerPath, 'reader');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
preparePackagedFrameCopyArtifacts(tempDir, 'darwin');
|
||||
assert.notEqual(
|
||||
fs.statSync(helperPath).mode & 0o111,
|
||||
0,
|
||||
'macOS helper must be executable after packaging'
|
||||
);
|
||||
}
|
||||
|
||||
preparePackagedFrameCopyArtifacts(tempDir, 'linux');
|
||||
assert.equal(
|
||||
fs.existsSync(helperPath),
|
||||
false,
|
||||
'Linux packages must omit the unsupported frame-copy helper'
|
||||
);
|
||||
|
||||
fs.writeFileSync(helperPath, '#!/bin/sh\n');
|
||||
removeStaleFrameCopyArtifacts(tempDir);
|
||||
assert.equal(fs.existsSync(helperPath), false);
|
||||
assert.equal(fs.existsSync(windowsHelperPath), false);
|
||||
assert.equal(fs.existsSync(readerPath), false);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows CI packages embedded MPV from a staged x64 runtime', () => {
|
||||
const requireEmbeddedMpvLines = buildAndMakeWorkflow
|
||||
.split(/\r?\n/)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FRAME_COPY_HELPER = 'iptvnator_mpv_helper';
|
||||
const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe';
|
||||
const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node';
|
||||
|
||||
function preparePackagedFrameCopyArtifacts(nativeDir, platform) {
|
||||
const helperPath = path.join(
|
||||
nativeDir,
|
||||
platform === 'win32'
|
||||
? WINDOWS_FRAME_COPY_HELPER
|
||||
: FRAME_COPY_HELPER
|
||||
);
|
||||
|
||||
if (platform === 'linux') {
|
||||
// Until Linux ships a bundled libmpv runtime, do not package a helper
|
||||
// linked against the build host's system library.
|
||||
fs.rmSync(helperPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform !== 'win32' && fs.existsSync(helperPath)) {
|
||||
// Asset copying drops POSIX modes; restore spawn permission.
|
||||
fs.chmodSync(helperPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleFrameCopyArtifacts(nativeDir) {
|
||||
for (const fileName of [
|
||||
FRAME_COPY_HELPER,
|
||||
WINDOWS_FRAME_COPY_HELPER,
|
||||
FRAME_COPY_READER,
|
||||
]) {
|
||||
fs.rmSync(path.join(nativeDir, fileName), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
preparePackagedFrameCopyArtifacts,
|
||||
removeStaleFrameCopyArtifacts,
|
||||
};
|
||||
@@ -540,6 +540,24 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// The frame-copy engine artifacts are built by the same binding.gyp
|
||||
// run as the addon; a macOS package that ships the addon without
|
||||
// them would silently lose the engine (support probe hides it).
|
||||
const missingFrameCopyArtifacts = [
|
||||
'iptvnator_mpv_helper',
|
||||
'embedded_mpv_frame_reader.node',
|
||||
]
|
||||
.map((name) => path.join(unpackedNativeDir, name))
|
||||
.filter((artifactPath) => !fs.existsSync(artifactPath));
|
||||
errors.push(
|
||||
...missingFrameCopyArtifacts.map(
|
||||
(artifactPath) =>
|
||||
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user