From 26ea7dc0503281e86f75687c5d410bc1bc378372 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 10 Jul 2026 23:35:02 +0200 Subject: [PATCH] feat(embedded-mpv): frame-copy engine wiring in main process and preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/app/api/embedded-mpv-frame-pump.ts | 283 ++++++++++++++ .../src/app/api/main.preload.ts | 7 + apps/electron-backend/src/app/app.ts | 12 +- .../src/app/events/embedded-mpv.events.ts | 5 + .../embedded-mpv-frame-copy.adapter.ts | 365 ++++++++++++++++++ .../services/embedded-mpv-native.service.ts | 126 +++++- .../src/lib/electron-api.interface.ts | 8 + .../src/lib/embedded-mpv-session.interface.ts | 21 + .../shared/interfaces/src/lib/ipc-commands.ts | 3 + 9 files changed, 826 insertions(+), 4 deletions(-) create mode 100644 apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts diff --git a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts new file mode 100644 index 000000000..c6192d413 --- /dev/null +++ b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts @@ -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 `` 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForCanvas(): Promise { + const deadline = Date.now() + ATTACH_TIMEOUT_MS; + while (Date.now() < deadline) { + const canvas = document.querySelector( + CANVAS_SELECTOR + ); + if (canvas) return canvas; + await delay(ATTACH_POLL_MS); + } + return null; +} + +async function waitForFrameSource( + sessionId: string +): Promise { + 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 { + 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; +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 0110962a5..43c7a2de7 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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 => ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), + attachEmbeddedMpvFrameView: (sessionId: string): Promise => + attachEmbeddedMpvFrameView(sessionId), + detachEmbeddedMpvFrameView: (): void => detachEmbeddedMpvFrameView(), autoUpdatePlaylists: ( playlists: Playlist[], options?: ElectronBridgeTrustOptions diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index e46865396..aee1581f9 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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'), diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index 307544024..38a76bf2f 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -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(); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts new file mode 100644 index 000000000..6bef6db63 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -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(); + + 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 + ): void { + switch (event.event) { + case 'snapshot': { + const { event: _ignored, ...snapshot } = event; + session.snapshot = { + ...session.snapshot, + ...(snapshot as Partial), + } 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' + ) + : ''; + } +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index fcdd8fc1b..8db3ed13e 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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([ '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; } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 3ad7c5b5e..489ed1b0e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -909,6 +909,14 @@ export interface ElectronBridgeApi { disposeEmbeddedMpvSession: ( sessionId: string ) => Promise; + /** + * Frame-copy engine only: start/stop the preload frame pump that + * uploads helper frames onto the renderer's + * `` element. Optional because older + * preload builds do not ship the pump. + */ + attachEmbeddedMpvFrameView?: (sessionId: string) => Promise; + detachEmbeddedMpvFrameView?: () => void; downloadsStart: ( data: ElectronBridgeDownloadStartPayload ) => Promise; diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 474189c28..4d1b696da 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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 { diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 40353ce94..7bd02a66b 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -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';