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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-15 18:20:45 +02:00
1 parent aabce726e3
commit 26ea7dc050
9 files changed
+826 -4

No files matched your search

@@ -0,0 +1,283 @@
/**
* 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 FrameReaderAddon {
open(shmName: string): FrameReaderInfo;
latestSeq(): number;
copyLatest(
buffer: ArrayBuffer
): { seq: number; ageMs: number; torn: boolean } | 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;
const VERTEX_SHADER = `#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);
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;
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;
if (reader.latestSeq() > pump.lastSeq) {
const result = reader.copyLatest(frame.buffer as ArrayBuffer);
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();
ensureSourceListener();
const canvas = await waitForCanvas();
if (!canvas) {
console.error('[embedded-mpv-pump] frame canvas not found in DOM');
return false;
}
const source = await waitForFrameSource(sessionId);
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 {
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
+11 -1
View File
@@ -79,10 +79,20 @@ 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 = ['1', 'true', 'yes', 'on'].includes(
(process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '')
.trim()
.toLowerCase()
);
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();
}
@@ -0,0 +1,365 @@
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)),
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
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) => {
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;
this.send(sessionId, 'quit');
const child = session.child;
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'
)
: '';
}
}
@@ -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,16 @@ 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';
interface NativeEmbeddedMpvSessionSnapshot {
export interface NativeEmbeddedMpvSessionSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
@@ -41,7 +45,7 @@ interface NativeEmbeddedMpvSessionSnapshot {
error?: string;
}
interface NativeEmbeddedMpvAddon {
export interface NativeEmbeddedMpvAddon {
isSupported(): boolean;
createSession(
windowHandle: Buffer,
@@ -77,6 +81,7 @@ interface EmbeddedMpvRuntimeSession {
}
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',
@@ -98,8 +103,87 @@ 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 {
return (
this.isFrameCopyEngineRequested() &&
process.platform === 'darwin' &&
process.arch === 'arm64'
);
}
getActiveEngine(): EmbeddedMpvEngine {
return this.isFrameCopyEngineActive() ? 'frame-copy' : 'native';
}
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 {
const candidates = this.getAddonCandidatePaths().map(
(candidatePath) =>
path.join(path.dirname(candidatePath), 'iptvnator_mpv_helper')
);
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
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',
@@ -147,6 +231,35 @@ export class EmbeddedMpvNativeService {
};
}
if (this.isFrameCopyEngineRequested()) {
if (!this.isFrameCopyEngineActive()) {
return {
supported: false,
platform: process.platform,
reason: 'The frame-copy embedded MPV engine is currently supported on Apple Silicon macOS only.',
};
}
if (!this.resolveFrameCopyHelperPath()) {
return {
supported: false,
platform: process.platform,
reason: 'The frame-copy embedded MPV helper binary was not found. Rebuild the native target (pnpm run serve:backend:embedded-mpv rebuilds it).',
};
}
return {
supported: true,
platform: process.platform,
engine: 'frame-copy',
capabilities: {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: true,
},
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
@@ -160,6 +273,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -224,6 +338,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -254,6 +369,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -841,6 +957,10 @@ export class EmbeddedMpvNativeService {
}
private getAddon(): NativeEmbeddedMpvAddon {
if (this.isFrameCopyEngineActive()) {
return this.getFrameCopyAdapter();
}
if (this.addon) {
return this.addon;
}
@@ -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,32 @@ 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;
}
/**
* 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 +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';