mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(embedded-mpv): frame-copy canvas mode in the player component + docs
EmbeddedMpvPlayerComponent renders <canvas data-embedded-mpv-frame> when support reports engine 'frame-copy' and the session controller starts/stops the preload frame pump around the session lifecycle. The bounds provider skips HIDDEN_BOUNDS and the popover cutout for this engine — the canvas is ordinary DOM, dialogs and popovers stack above it natively; bounds sync still drives the helper's render size. Adapter unit tests cover spawn args, snapshot caching, shm generations, protocol encoding, unexpected-exit mapping, and dispose escalation. Architecture doc and CLAUDE.md describe the engine, its flag, and the sandbox trade-off. Verified end to end in the built app (M1 Pro): engine detection, helper spawn, lavfi playback onto the canvas via CDP-injected smoke — including an orientation fix (helper FLIP_Y already yields texture-order rows; the pump shader must not flip uv again). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26ea7dc050
commit
f4da4d6074
8 files changed
+281
-1
No files matched your search
@@ -617,6 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Built-in HTML5 player with HLS.js or Video.js
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon only, `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
|
||||
@@ -46,11 +46,15 @@ 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 = vec2(corner.x, 1.0 - corner.y);
|
||||
v_uv = corner;
|
||||
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const spawnMock = jest.fn();
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
||||
|
||||
class FakeHelperProcess extends EventEmitter {
|
||||
exitCode: number | null = null;
|
||||
readonly stdout = new EventEmitter();
|
||||
readonly stderr = new EventEmitter();
|
||||
readonly stdin = {
|
||||
writable: true,
|
||||
written: [] as string[],
|
||||
write(line: string) {
|
||||
this.written.push(line);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
readonly kill = jest.fn((signal?: string) => {
|
||||
this.exitCode = 0;
|
||||
this.emit('exit', 0, signal ?? null);
|
||||
return true;
|
||||
});
|
||||
|
||||
emitStdout(payload: object): void {
|
||||
this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`));
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
let child: FakeHelperProcess;
|
||||
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>;
|
||||
let adapter: EmbeddedMpvFrameCopyAdapter;
|
||||
|
||||
const createAdapter = (helperPath: string | null = '/native/helper') => {
|
||||
frameSourceChanges = [];
|
||||
return new EmbeddedMpvFrameCopyAdapter({
|
||||
resolveHelperPath: () => helperPath,
|
||||
getScaleFactor: () => 2,
|
||||
onFrameSourceChanged: (sessionId, source) =>
|
||||
frameSourceChanges.push({ sessionId, shmName: source.shmName }),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
child = new FakeHelperProcess();
|
||||
spawnMock.mockReset();
|
||||
spawnMock.mockReturnValue(child);
|
||||
adapter = createAdapter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const createSession = () =>
|
||||
adapter.createSession(
|
||||
Buffer.alloc(0),
|
||||
{ x: 0, y: 0, width: 640, height: 360 },
|
||||
'Title',
|
||||
0.8
|
||||
);
|
||||
|
||||
it('spawns the helper with device-pixel size and initial volume', () => {
|
||||
const sessionId = createSession();
|
||||
expect(sessionId).toMatch(/^impv-fc-/);
|
||||
const [helperPath, args] = spawnMock.mock.calls[0];
|
||||
expect(helperPath).toBe('/native/helper');
|
||||
expect(args).toEqual([
|
||||
'--shm-base',
|
||||
`/${sessionId}`,
|
||||
'--width',
|
||||
'1280',
|
||||
'--height',
|
||||
'720',
|
||||
'--volume',
|
||||
'0.8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('caches helper snapshot events for getSessionSnapshot', () => {
|
||||
const sessionId = createSession();
|
||||
child.emitStdout({
|
||||
event: 'snapshot',
|
||||
status: 'playing',
|
||||
positionSeconds: 12.5,
|
||||
durationSeconds: 60,
|
||||
volume: 0.8,
|
||||
streamUrl: 'http://stream',
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
});
|
||||
const snapshot = adapter.getSessionSnapshot(sessionId);
|
||||
expect(snapshot?.status).toBe('playing');
|
||||
expect(snapshot?.positionSeconds).toBe(12.5);
|
||||
expect(snapshot?.streamUrl).toBe('http://stream');
|
||||
});
|
||||
|
||||
it('publishes shm generations through onFrameSourceChanged', () => {
|
||||
const sessionId = createSession();
|
||||
child.emitStdout({
|
||||
event: 'shm',
|
||||
name: `/${sessionId}-g1`,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
generation: 1,
|
||||
});
|
||||
expect(frameSourceChanges).toEqual([
|
||||
{ sessionId, shmName: `/${sessionId}-g1` },
|
||||
]);
|
||||
expect(adapter.getFrameSource(sessionId)?.readerPath).toBe(
|
||||
'/native/embedded_mpv_frame_reader.node'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes loadfile options with percent-escaping', () => {
|
||||
const sessionId = createSession();
|
||||
adapter.loadPlayback(sessionId, {
|
||||
streamUrl: 'http://host/live.m3u8',
|
||||
title: 'Tab\there',
|
||||
userAgent: 'UA 1.0',
|
||||
startTime: 42,
|
||||
headers: { 'X-Token': 'abc' },
|
||||
});
|
||||
const line = child.stdin.written.at(-1) ?? '';
|
||||
expect(line.startsWith('load\turl=http://host/live.m3u8\t')).toBe(true);
|
||||
expect(line).toContain('opt.force-media-title=Tab%09here');
|
||||
expect(line).toContain('opt.user-agent=UA 1.0');
|
||||
expect(line).toContain('opt.start=42');
|
||||
expect(line).toContain('opt.http-header-fields=X-Token: abc');
|
||||
});
|
||||
|
||||
it('scales bounds and ignores hidden/degenerate bounds', () => {
|
||||
const sessionId = createSession();
|
||||
adapter.setBounds(sessionId, { x: 0, y: 0, width: 800, height: 450 });
|
||||
expect(child.stdin.written.at(-1)).toBe(
|
||||
'size\twidth=1600\theight=900\n'
|
||||
);
|
||||
const writesBefore = child.stdin.written.length;
|
||||
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
|
||||
expect(child.stdin.written.length).toBe(writesBefore);
|
||||
});
|
||||
|
||||
it('maps an unexpected helper exit to a session error', () => {
|
||||
const sessionId = createSession();
|
||||
child.exitCode = 1;
|
||||
child.emit('exit', 1, null);
|
||||
const snapshot = adapter.getSessionSnapshot(sessionId);
|
||||
expect(snapshot?.status).toBe('error');
|
||||
expect(snapshot?.error).toContain('exited unexpectedly');
|
||||
});
|
||||
|
||||
it('disposes with quit and escalates to SIGTERM', () => {
|
||||
const sessionId = createSession();
|
||||
adapter.disposeSession(sessionId);
|
||||
expect(child.stdin.written.at(-1)).toBe('quit\n');
|
||||
expect(adapter.getSessionSnapshot(sessionId)).toBeNull();
|
||||
child.exitCode = null; // helper ignored quit
|
||||
jest.advanceTimersByTime(600);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
it('reports unsupported without a helper binary', () => {
|
||||
const withoutHelper = createAdapter(null);
|
||||
expect(withoutHelper.isSupported()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,59 @@ The MPV video surface is a native platform view/window, not a normal DOM element
|
||||
|
||||
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
|
||||
|
||||
## Frame-Copy Engine (Experimental, Apple Silicon Only)
|
||||
|
||||
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
|
||||
embedded MPV experiment flag) switches macOS/arm64 to a second rendering
|
||||
engine that replaces the native-view compositing entirely:
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a
|
||||
one-process-per-session libmpv host. It decodes (hwdec), renders
|
||||
offscreen at viewport size (headless CGL + async PBO readback ring),
|
||||
publishes BGRA frames into a POSIX shm seqlock ring
|
||||
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
|
||||
plays audio directly. Control protocol: tab-separated commands on stdin,
|
||||
JSON events on stdout; the `snapshot` event mirrors
|
||||
`NativeEmbeddedMpvSessionSnapshot`. Status semantics are ported from
|
||||
`embedded_mpv.mm`.
|
||||
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` —
|
||||
implements the same `NativeEmbeddedMpvAddon` surface over the helper
|
||||
process, so `EmbeddedMpvNativeService` (polling, diffing, power blocker,
|
||||
recording paths) is reused unchanged. The flag routes `getAddon()` to the
|
||||
adapter and support reports `engine: 'frame-copy'`.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API
|
||||
shm reader loaded by the preload frame pump
|
||||
(`apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts`): copy
|
||||
the newest complete frame into a reused ArrayBuffer once per rAF and
|
||||
upload it to a WebGL2 texture on the renderer's
|
||||
`<canvas data-embedded-mpv-frame>` (BGRA swizzle in the shader). Frame
|
||||
data never crosses the contextBridge; the bridge only exposes
|
||||
`attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`.
|
||||
- Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when
|
||||
`support.engine === 'frame-copy'` and skips the compositor workarounds —
|
||||
no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs
|
||||
and controls stack above the canvas as ordinary DOM. Bounds sync still
|
||||
runs: the helper re-renders at the new viewport size (device pixels via
|
||||
the display scale factor).
|
||||
|
||||
Trade-offs and constraints:
|
||||
|
||||
- The experiment flag relaxes the BrowserWindow sandbox (preload must
|
||||
`require` the reader addon); `contextIsolation` and
|
||||
`nodeIntegration:false` stay on. The sandbox story must be revisited
|
||||
before this engine can become a default — candidates: utilityProcess +
|
||||
MessagePort (costs one extra copy + GC churn since Electron ports clone
|
||||
ArrayBuffers) or a WebCodecs-based path.
|
||||
- Scope: Apple Silicon only by owner decision (2026-07-10); Intel Macs
|
||||
keep the native-view engine. Windows/Linux ports of the helper (WGL/EGL)
|
||||
are future work — the shm protocol and adapter are platform-agnostic.
|
||||
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
|
||||
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
|
||||
produce-to-upload latency, zero torn frames over a 10-minute run.
|
||||
- Helper crash isolation: an unexpected helper exit surfaces as a session
|
||||
`error` (renderer falls back); it can never take down the Electron main
|
||||
process, unlike in-process libmpv.
|
||||
|
||||
## Resume And Track Handling
|
||||
|
||||
`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers.
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
class="embedded-mpv-player__viewport"
|
||||
(click)="onViewportClick($event)"
|
||||
>
|
||||
@if (isFrameCopyEngine()) {
|
||||
<!-- Frame-copy engine: the preload frame pump paints helper
|
||||
frames onto this canvas. It is ordinary DOM, so controls
|
||||
and dialogs stack above it without native-layer tricks. -->
|
||||
<canvas
|
||||
class="embedded-mpv-player__frame-canvas"
|
||||
data-embedded-mpv-frame
|
||||
></canvas>
|
||||
}
|
||||
@if (isErrored()) {
|
||||
<div
|
||||
class="embedded-mpv-player__stalled"
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
// Frame-copy engine: video frames are painted onto this canvas by the
|
||||
// preload frame pump. The backing store is sized to the viewport in device
|
||||
// pixels; CSS letterboxes it inside the viewport.
|
||||
.embedded-mpv-player__frame-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport {
|
||||
bottom: var(--embedded-mpv-controls-height);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly aspectPresets = ASPECT_PRESETS;
|
||||
|
||||
readonly isSupported = computed(() => this.support()?.supported ?? false);
|
||||
readonly isFrameCopyEngine = computed(
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
readonly capabilities = computed(
|
||||
() =>
|
||||
this.support()?.capabilities ?? {
|
||||
@@ -343,6 +346,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
this.controller.setBoundsProvider((host) => {
|
||||
// The frame-copy engine paints into an ordinary DOM canvas:
|
||||
// dialogs and popovers stack above it natively, so the
|
||||
// hide-offscreen and popover-cutout compositor workarounds
|
||||
// must not shrink its render size.
|
||||
if (this.isFrameCopyEngine()) {
|
||||
return measureBounds(host);
|
||||
}
|
||||
if (this.overlayVisibility.overlayActive()) {
|
||||
return HIDDEN_BOUNDS;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export class EmbeddedMpvSessionController {
|
||||
readonly stalled = signal(false);
|
||||
readonly retryToken = signal(0);
|
||||
|
||||
readonly isFrameCopyEngine = computed(
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
|
||||
private readonly sessionStatus = computed(
|
||||
() => this.session()?.status ?? null
|
||||
);
|
||||
@@ -182,6 +186,14 @@ export class EmbeddedMpvSessionController {
|
||||
this.sessionId.set(created.id);
|
||||
this.session.set(created);
|
||||
await electron.loadEmbeddedMpvPlayback(created.id, playback);
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
// Frame-copy engine: start the preload frame pump that
|
||||
// paints helper frames onto the component's canvas. Failure
|
||||
// is non-fatal here — the session error/stall paths cover it.
|
||||
void electron
|
||||
.attachEmbeddedMpvFrameView?.(created.id)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
@@ -217,6 +229,9 @@ export class EmbeddedMpvSessionController {
|
||||
this.session.set(null);
|
||||
|
||||
if (id) {
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
window.electron?.detachEmbeddedMpvFrameView?.();
|
||||
}
|
||||
void window.electron?.disposeEmbeddedMpvSession(id);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user