feat(embedded-mpv): add Windows frame-copy support (#1175)

Port the embedded mpv frame-copy pipeline to Windows with WGL rendering and named shared memory. Includes packaging validation, platform gates, tests, and architecture documentation.
This commit is contained in:
4gray authored and GitHub committed 2026-07-15 21:27:56 +02:00
1 parent 7d75d989e8
commit 59e08fd2d6
41 files changed
+1284 -157

No files matched your search

+6 -3
View File
@@ -1,6 +1,6 @@
# Embedded MPV Runtime
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon.
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon. On Windows the same binding.gyp run builds `iptvnator_mpv_helper.exe` against the staged vendored runtime (import library + DLL, resolved from the helper's own directory at runtime) plus `opengl32.lib`; no toolchain beyond the MSVC workload and Windows SDK that node-gyp already requires.
## Runtime Policy
@@ -110,7 +110,10 @@ libmpv import library, and `mpv-2.dll`/`mpv.dll` or
(`lib/` and `bin/`) or the common `mpv-dev-lgpl` flat layout with the import
library and DLL in the archive root. The staged runtime preserves the DLL
basename from the archive because Windows import libraries encode the DLL name
that `embedded_mpv.node` must load at runtime. If
that native binaries must load at runtime. Package validation reads the
frame-copy helper's PE imports and requires that exact DLL basename beside
`iptvnator_mpv_helper.exe`; a different accepted MPV DLL name or a copy only
under `native/lib/` is not sufficient. If
`runtime-manifest.json` is missing, CI generates a minimal manifest from the
archive URL/path and checksum; release-ready runtime archives should still
provide full source/build metadata.
@@ -147,5 +150,5 @@ Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that mu
## Platform Notes
- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS.
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`.
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`. The experimental frame-copy engine instead renders offscreen through WGL into the app canvas (no child window) and shares frames over a session-local named file mapping.
- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id. The experimental frame-copy engine has no window embedding at all (offscreen EGL into a renderer canvas) and therefore works under native Wayland — dev builds only for now.
@@ -102,6 +102,37 @@ const embeddedMpvWin32Source = fs.readFileSync(
);
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
function writeWindowsHelperFixture(helperPath, importedDllName) {
const peOffset = 0x80;
const optionalHeaderOffset = peOffset + 24;
const optionalHeaderSize = 0xf0;
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
const importRva = 0x1000;
const importRawOffset = 0x200;
const importNameOffset = 0x30;
const image = Buffer.alloc(0x400);
image.write('MZ', 0, 'ascii');
image.writeUInt32LE(peOffset, 0x3c);
image.write('PE\0\0', peOffset, 'ascii');
image.writeUInt16LE(0x8664, peOffset + 4);
image.writeUInt16LE(1, peOffset + 6);
image.writeUInt16LE(optionalHeaderSize, peOffset + 20);
image.writeUInt16LE(0x20b, optionalHeaderOffset);
image.writeUInt32LE(0x200, optionalHeaderOffset + 60);
image.writeUInt32LE(16, optionalHeaderOffset + 108);
image.writeUInt32LE(importRva, optionalHeaderOffset + 120);
image.writeUInt32LE(40, optionalHeaderOffset + 124);
image.write('.idata\0\0', sectionTableOffset, 'ascii');
image.writeUInt32LE(0x200, sectionTableOffset + 8);
image.writeUInt32LE(importRva, sectionTableOffset + 12);
image.writeUInt32LE(0x200, sectionTableOffset + 16);
image.writeUInt32LE(importRawOffset, sectionTableOffset + 20);
image.writeUInt32LE(importRva + importNameOffset, importRawOffset + 12);
image.write(`${importedDllName}\0`, importRawOffset + importNameOffset);
fs.writeFileSync(helperPath, image);
}
test('Linux package identity does not expose the internal Electron backend project name', () => {
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
assert.equal(electronBuilderConfig.extraMetadata?.name, 'iptvnator');
@@ -119,8 +150,10 @@ test('Linux package identity does not expose the internal Electron backend proje
});
test('GitHub Releases auto-update metadata is generated and uploaded', () => {
// \r?\n keeps this host-agnostic: Windows checkouts with autocrlf see
// CRLF in the workflow file.
const releaseFiles = buildAndMakeWorkflow.match(
/files: \|\n([\s\S]*?)\n\s+env:/
/files: \|\r?\n([\s\S]*?)\r?\n\s+env:/
)?.[1];
assert.ok(releaseFiles, 'release upload files block must exist');
@@ -304,10 +337,16 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
for (const [platform, runtimeFile] of [
['windows', 'mpv-2.dll'],
['windows', 'libmpv-2.dll'],
['windows', join('lib', 'mpv.dll')],
['windows', join('lib', 'libmpv.dll')],
['windows', 'mpv.dll'],
['windows', 'libmpv.dll'],
]) {
const resourceDir = join(tempDir, platform);
// One fixture dir per runtime-file scenario: the frame-copy
// artifacts written below must not leak into the next
// iteration's missing-artifact assertions.
const resourceDir = join(
tempDir,
`${platform}-${runtimeFile.replace(/[\\/]/g, '_')}`
);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
@@ -322,6 +361,32 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
);
fs.writeFileSync(join(nativeDir, runtimeFile), '');
// Windows packages that ship the addon must also ship the
// frame-copy engine artifacts built by the same binding.gyp run.
const missingWindowsFrameCopyErrors = validatePackagedEmbeddedMpv(
resourceDir,
{ platform, required: true }
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('iptvnator_mpv_helper.exe')
)
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('embedded_mpv_frame_reader.node')
)
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
assert.deepEqual(
validatePackagedEmbeddedMpv(resourceDir, {
platform,
@@ -371,10 +436,10 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
// Host-agnostic assertion: on non-macOS hosts the validator also
// reports that link validation needs a macOS host, so only the
// frame-copy artifact requirement is asserted here.
const remainingErrors = validatePackagedEmbeddedMpv(
darwinResourceDir,
{ platform: 'darwin', required: true }
);
const remainingErrors = validatePackagedEmbeddedMpv(darwinResourceDir, {
platform: 'darwin',
required: true,
});
assert.ok(
!remainingErrors.some((error) =>
error.includes('frame-copy artifact')
@@ -408,6 +473,138 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
}
});
test('Windows frame-copy packages reject an mpv DLL that exists only under native/lib', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
for (const runtimeFile of [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
]) {
const resourceDir = join(tempDir, runtimeFile);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
fs.writeFileSync(join(nativeDir, 'lib', runtimeFile), '');
const errors = validatePackagedEmbeddedMpv(resourceDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes(
'beside the Windows frame-copy helper'
) && error.includes(nativeDir)
),
`${runtimeFile} under native/lib must not satisfy the helper DLL requirement: ${errors.join('; ')}`
);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy packages require the DLL imported by the helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
'mpv-2.dll'
);
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'libmpv.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes('imports mpv-2.dll') &&
error.includes(join(nativeDir, 'mpv-2.dll'))
),
`a different accepted DLL must not satisfy the helper import: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy package validation fails closed for a malformed helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), 'MZ');
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'mpv-2.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some((error) =>
error.includes(
'Unable to inspect Windows frame-copy helper imports'
)
),
`malformed helper must fail package validation: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows embedded MPV staging preserves import-library DLL basenames', () => {
assert.match(
embeddedMpvStageRuntimeSource,
@@ -472,10 +669,7 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
try {
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
const windowsHelperPath = join(
tempDir,
'iptvnator_mpv_helper.exe'
);
const windowsHelperPath = join(tempDir, 'iptvnator_mpv_helper.exe');
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
fs.writeFileSync(helperPath, '#!/bin/sh\n');
fs.chmodSync(helperPath, 0o644);
+267 -15
View File
@@ -4,6 +4,12 @@ const { spawnSync } = require('child_process');
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
const windowsMpvRuntimeNames = [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -67,6 +73,177 @@ function listRuntimeFiles(directoryPath) {
.sort();
}
function assertPeRange(image, offset, size, label) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size < 0 ||
offset + size > image.length
) {
throw new Error(`Invalid PE image: ${label} is out of range.`);
}
}
function readPeImportedDllNames(binaryPath) {
const image = fs.readFileSync(binaryPath);
assertPeRange(image, 0, 0x40, 'DOS header');
if (image.toString('ascii', 0, 2) !== 'MZ') {
throw new Error('Invalid PE image: missing DOS signature.');
}
const peOffset = image.readUInt32LE(0x3c);
assertPeRange(image, peOffset, 24, 'PE header');
if (image.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error('Invalid PE image: missing PE signature.');
}
const sectionCount = image.readUInt16LE(peOffset + 6);
const optionalHeaderSize = image.readUInt16LE(peOffset + 20);
const optionalHeaderOffset = peOffset + 24;
assertPeRange(
image,
optionalHeaderOffset,
optionalHeaderSize,
'optional header'
);
const optionalMagic = image.readUInt16LE(optionalHeaderOffset);
const dataDirectoryOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 112
: optionalMagic === 0x10b
? optionalHeaderOffset + 96
: null;
const directoryCountOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 108
: optionalMagic === 0x10b
? optionalHeaderOffset + 92
: null;
if (dataDirectoryOffset === null || directoryCountOffset === null) {
throw new Error(
`Invalid PE image: unsupported optional-header magic 0x${optionalMagic.toString(16)}.`
);
}
const optionalHeaderEnd = optionalHeaderOffset + optionalHeaderSize;
if (
directoryCountOffset + 4 > optionalHeaderEnd ||
dataDirectoryOffset + 16 > optionalHeaderEnd
) {
throw new Error(
'Invalid PE image: data directories exceed the optional header.'
);
}
assertPeRange(image, directoryCountOffset, 4, 'data-directory count');
if (image.readUInt32LE(directoryCountOffset) < 2) {
return [];
}
assertPeRange(image, dataDirectoryOffset + 8, 8, 'import directory');
const importRva = image.readUInt32LE(dataDirectoryOffset + 8);
const importSize = image.readUInt32LE(dataDirectoryOffset + 12);
if (importRva === 0 && importSize === 0) {
return [];
}
if (importRva === 0 || importSize < 20) {
throw new Error('Invalid PE image: malformed import directory.');
}
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
assertPeRange(
image,
sectionTableOffset,
sectionCount * 40,
'section table'
);
const sections = Array.from({ length: sectionCount }, (_, index) => {
const offset = sectionTableOffset + index * 40;
return {
virtualSize: image.readUInt32LE(offset + 8),
virtualAddress: image.readUInt32LE(offset + 12),
rawSize: image.readUInt32LE(offset + 16),
rawOffset: image.readUInt32LE(offset + 20),
};
});
const rvaToLocation = (rva, label) => {
const section = sections.find(
(candidate) =>
rva >= candidate.virtualAddress &&
rva <
candidate.virtualAddress +
Math.max(candidate.virtualSize, candidate.rawSize)
);
if (!section) {
throw new Error(
`Invalid PE image: ${label} RVA 0x${rva.toString(16)} is not mapped.`
);
}
const sectionOffset = rva - section.virtualAddress;
if (sectionOffset >= section.rawSize) {
throw new Error(
`Invalid PE image: ${label} is outside section file data.`
);
}
const offset = section.rawOffset + sectionOffset;
assertPeRange(image, offset, 1, label);
return {
offset,
sectionEnd: section.rawOffset + section.rawSize,
};
};
const importedDllNames = [];
const descriptorCount = Math.floor(importSize / 20);
let foundTerminator = false;
for (let index = 0; index < descriptorCount; index += 1) {
const descriptorLocation = rvaToLocation(
importRva + index * 20,
'import descriptor'
);
const descriptorOffset = descriptorLocation.offset;
if (descriptorOffset + 20 > descriptorLocation.sectionEnd) {
throw new Error(
'Invalid PE image: import descriptor crosses section file data.'
);
}
assertPeRange(image, descriptorOffset, 20, 'import descriptor');
const descriptor = image.subarray(
descriptorOffset,
descriptorOffset + 20
);
if (descriptor.every((value) => value === 0)) {
foundTerminator = true;
break;
}
const nameRva = image.readUInt32LE(descriptorOffset + 12);
const nameLocation = rvaToLocation(nameRva, 'imported DLL name');
const nameOffset = nameLocation.offset;
const nameEnd = image.indexOf(0, nameOffset);
if (nameEnd < 0 || nameEnd >= nameLocation.sectionEnd) {
throw new Error(
'Invalid PE image: imported DLL name is not terminated.'
);
}
const name = image.toString('ascii', nameOffset, nameEnd);
if (!name) {
throw new Error('Invalid PE image: imported DLL name is empty.');
}
importedDllNames.push(name);
}
if (!foundTerminator) {
throw new Error(
'Invalid PE image: import descriptor table has no terminator.'
);
}
return [...new Set(importedDllNames)];
}
function parseOtoolDependencies(binaryPath) {
const output = run('otool', ['-L', binaryPath]);
return output
@@ -426,16 +603,12 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
path.join(libDir, 'libmpv.dylib'),
];
case 'win32':
return [
nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'mpv.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv.dll') : null,
path.join(libDir, 'mpv-2.dll'),
path.join(libDir, 'libmpv-2.dll'),
path.join(libDir, 'mpv.dll'),
path.join(libDir, 'libmpv.dll'),
].filter(Boolean);
return windowsMpvRuntimeNames.flatMap((name) =>
[
nativeDir ? path.join(nativeDir, name) : null,
path.join(libDir, name),
].filter(Boolean)
);
case 'linux':
return [];
default:
@@ -480,7 +653,11 @@ function getEmbeddedMpvAddonArch(env = process.env) {
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
*/
function isForeignLinuxEmbeddedMpvArch(platform, targetArch, env = process.env) {
function isForeignLinuxEmbeddedMpvArch(
platform,
targetArch,
env = process.env
) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
@@ -540,12 +717,16 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
return errors;
}
if (platform === 'darwin') {
if (platform === 'darwin' || platform === 'win32') {
// The frame-copy engine artifacts are built by the same binding.gyp
// run as the addon; a macOS package that ships the addon without
// them would silently lose the engine (support probe hides it).
// run as the addon; a macOS/Windows package that ships the addon
// without them would silently lose the engine (support probe hides
// it). Linux packages intentionally strip the helper until the
// bundled-libmpv runtime lands (see electron-after-pack.cjs).
const missingFrameCopyArtifacts = [
'iptvnator_mpv_helper',
platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper',
'embedded_mpv_frame_reader.node',
]
.map((name) => path.join(unpackedNativeDir, name))
@@ -556,6 +737,77 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
)
);
if (platform === 'win32') {
// The helper is a separate executable. Windows resolves its
// imported libmpv DLL from the executable directory, so a copy
// under native/lib may satisfy addon bookkeeping but cannot
// start iptvnator_mpv_helper.exe.
const helperPath = path.join(
unpackedNativeDir,
'iptvnator_mpv_helper.exe'
);
const helperRuntimeCandidates = windowsMpvRuntimeNames.map((name) =>
path.join(unpackedNativeDir, name)
);
if (
!helperRuntimeCandidates.some((candidate) =>
fs.existsSync(candidate)
)
) {
errors.push(
[
`Missing bundled MPV DLL beside the Windows frame-copy helper in ${unpackedNativeDir}.`,
'Expected one of:',
...helperRuntimeCandidates.map(
(candidate) => `- ${candidate}`
),
].join('\n')
);
}
if (fs.existsSync(helperPath)) {
try {
const acceptedRuntimeNames = new Set(
windowsMpvRuntimeNames.map((name) => name.toLowerCase())
);
const importedRuntimeNames = readPeImportedDllNames(
helperPath
).filter((name) =>
acceptedRuntimeNames.has(name.toLowerCase())
);
if (importedRuntimeNames.length === 0) {
errors.push(
`Windows frame-copy helper does not import a supported MPV DLL: ${helperPath}`
);
}
const packagedRuntimeNames = new Set(
fs
.readdirSync(unpackedNativeDir, {
withFileTypes: true,
})
.filter((entry) => entry.isFile())
.map((entry) => entry.name.toLowerCase())
);
for (const importedRuntimeName of importedRuntimeNames) {
if (
!packagedRuntimeNames.has(
importedRuntimeName.toLowerCase()
)
) {
errors.push(
`Windows frame-copy helper imports ${importedRuntimeName}, but the matching DLL is missing beside it: ${path.join(unpackedNativeDir, importedRuntimeName)}`
);
}
}
} catch (error) {
errors.push(
`Unable to inspect Windows frame-copy helper imports at ${helperPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
}
if (!fs.existsSync(manifestPath)) {