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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:20:45 +02:00

298 lines
8.8 KiB
TypeScript

/**
* 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;
// Note on orientation: the helper renders with MPV_RENDER_PARAM_FLIP_Y and
// glReadPixels reads rows bottom-up, so the shm buffer arrives in texture
// order already — sampling uses the un-flipped uv (a second flip here would
// show the video upside down).
const VERTEX_SHADER = `#version 300 es
out vec2 v_uv;
void main() {
vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
v_uv = corner;
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAGMENT_SHADER = `#version 300 es
precision mediump float;
uniform sampler2D u_tex;
in vec2 v_uv;
out vec4 o_color;
void main() {
vec4 c = texture(u_tex, v_uv);
o_color = vec4(c.b, c.g, c.r, 1.0);
}`;
interface PumpState {
sessionId: string;
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
texture: WebGLTexture;
reader: FrameReaderAddon;
frame: Uint8Array;
width: number;
height: number;
lastSeq: number;
rafHandle: number;
}
let pump: PumpState | null = null;
let sourceListenerRegistered = false;
/**
* Monotonic attach epoch: bumped by every attach/detach. Async attach waits
* re-check it after each await so a stale attach (for a session that was
* replaced meanwhile) can never install itself over the active pump.
*/
let attachEpoch = 0;
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();
const epoch = ++attachEpoch;
ensureSourceListener();
const canvas = await waitForCanvas();
if (epoch !== attachEpoch) return false;
if (!canvas) {
console.error('[embedded-mpv-pump] frame canvas not found in DOM');
return false;
}
const source = await waitForFrameSource(sessionId);
if (epoch !== attachEpoch) return false;
if (!source) {
console.error('[embedded-mpv-pump] no frame source for', sessionId);
return false;
}
let reader: FrameReaderAddon;
try {
reader = nodeRequire(source.readerPath) as FrameReaderAddon;
} catch (error) {
console.error('[embedded-mpv-pump] failed to load reader:', error);
return false;
}
const glSetup = createGl(canvas);
if (!glSetup) {
console.error('[embedded-mpv-pump] WebGL2 unavailable');
return false;
}
const state: PumpState = {
sessionId,
canvas,
gl: glSetup.gl,
texture: glSetup.texture,
reader,
frame: new Uint8Array(0),
width: 0,
height: 0,
lastSeq: 0,
rafHandle: 0,
};
if (!applySource(state, source)) {
reader.close();
return false;
}
pump = state;
pump.rafHandle = requestAnimationFrame(pumpTick);
return true;
}
export function detachEmbeddedMpvFrameView(): void {
attachEpoch++; // aborts any attach still waiting on canvas/frame source
if (!pump) return;
cancelAnimationFrame(pump.rafHandle);
try {
pump.reader.close();
} catch {
// reader may already be gone with the helper; nothing to release
}
pump = null;
}