mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:16:43 -08:00
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:
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user