@eplayer/epl
v0.0.3
Published
EPL/EMP4 streaming video player library
Maintainers
Readme
@eplayer/epl
EPL (Encrypted Player Library) — 加密视频流媒体播放器库。支持 AES-256-GCM 加密视频解密、自适应码率 (ABR) 流媒体播放、PIN 码保护,以及 DPlayer / Video.js / 原生 <video> 集成。
特性
- 加密播放 — AES-256-GCM 解密,PBKDF2-SHA256 密钥派生(100000 次迭代)
- 三种密钥模式 — 密钥服务器 / 密码派生 / PIN 码保护
- 自适应码率 (ABR) — 基于带宽估计自动切换画质,支持手动选择
- EMP4 格式 — 自定义加密容器格式(Encrypted MP4),含 128 字节文件头、分块加密、元数据区
- EPL 清单 — JSON 格式清单文件,描述多画质流、分片列表与加密配置
- MSE 播放 — 基于 Media Source Extensions 的缓冲区管理
- 多播放器集成 — DPlayer、Video.js、原生
<video>插件 - 拖拽播放 — 支持拖拽 EPL/EMP4/普通视频文件到播放器直接播放
- 跨平台 — 浏览器、Electron 环境,自动检测并适配
- TypeScript — 完整类型定义,严格模式编译
- 多格式输出 — UMD / ESM / CJS
安装
npm install @eplayer/epl快速开始
EPL 流媒体播放
import EPL from '@eplayer/epl';
const player = new EPL(videoElement, {
keyServer: 'https://key-server.example.com',
authToken: 'your-auth-token',
password: 'optional-password'
});
player.on(EPL.Events.MANIFEST_LOADED, (event, data) => {
console.log('Manifest loaded:', data.manifest);
});
player.loadSource('https://example.com/video.manifest.epl');
player.attachMedia();
player.startLoad();EMP4 单文件播放
import { PlayerFactory } from '@eplayer/epl';
const player = PlayerFactory.createEMP4Player(videoElement, {
password: 'encryption-password'
});
await player.load('https://example.com/video.emp4');
// 或本地文件
await player.load(file); // File 对象一键播放
import { PlayerFactory } from '@eplayer/epl';
const eplPlayer = await PlayerFactory.playEPL(
videoElement,
'https://example.com/video.manifest.epl',
{ keyServer: 'https://key-server.example.com' }
);
const emp4Player = await PlayerFactory.playEMP4(
videoElement,
emp4File,
{ password: 'encryption-password' }
);跨平台支持
EPL 提供完整的跨平台支持,通过环境适配器(Environment Adapter)统一抽象不同平台的差异。
支持的平台
| 平台 | 文件访问 | 网络请求 | 加密解密 | MSE 播放 | 边解密边播放 | |------|---------|---------|---------|---------|-------------| | 浏览器 | File API / fetch | fetch | Web Crypto | ✅ | ❌ | | Electron 渲染进程 | IPC + 主进程 | fetch | Web Crypto | ✅ | ✅ | | Node.js | fs 模块 | http/https | crypto.webcrypto | ❌ | ❌ |
平台能力检测
import {
detectEnvironment,
detectPlatform,
createAdapter,
getEnvironmentInfo
} from '@eplayer/epl';
// 自动检测当前环境并创建适配器
const { platform, capabilities, adapter } = detectEnvironment(true);
console.log('当前平台:', platform); // 'browser' | 'electron' | 'node'
console.log('平台能力:', capabilities);
// 获取详细环境信息
const envInfo = getEnvironmentInfo();
console.log(envInfo);
// {
// isNode: false,
// isBrowser: true,
// isElectron: false,
// isElectronRenderer: false,
// hasWebCrypto: true,
// hasWorker: true,
// hasXHR: true,
// hasFetch: true,
// hasMSE: true,
// hasVideoElement: true,
// canPlayback: true,
// cpuCores: 8
// }平台能力接口
interface PlatformCapabilities {
canReadLocalFile: boolean; // 是否支持直接读取本地文件
supportsStreamingDecrypt: boolean; // 是否支持流式解密
supportsFastDecrypt: boolean; // 是否支持边解密边播放
hasWebCrypto: boolean; // 是否支持 Web Crypto API
hasMSE: boolean; // 是否支持 Media Source Extensions
}环境适配器接口
所有平台适配器实现统一的 IEnvironmentAdapter 接口:
interface IEnvironmentAdapter {
readonly platform: Platform;
readonly capabilities: PlatformCapabilities;
fileSystem: IFileSystem; // 文件系统操作
network: INetwork; // 网络请求
crypto: ICrypto; // 加密解密
emp4PlayerFactory: IEMP4PlayerFactory; // EMP4 播放器工厂
initialize(): Promise<void>;
destroy(): void;
}浏览器环境
浏览器环境使用原生 Web API:
- 文件访问:通过
fetch加载远程文件,或通过FileAPI 读取用户选择的本地文件 - 网络请求:使用原生
fetchAPI - 加密解密:使用 Web Crypto API(
crypto.subtle) - 播放:使用 Media Source Extensions
import { BrowserAdapter } from '@eplayer/epl';
const adapter = new BrowserAdapter(true);
await adapter.initialize();
// 通过 fetch 读取远程文件
const data = await adapter.fileSystem.readFile('https://example.com/video.emp4');
// 解密 EMP4 文件
const decrypted = await adapter.crypto.decryptEMP4(data, {
password: 'encryption-password'
});
// 创建 EMP4 播放器
const player = adapter.emp4PlayerFactory.createPlayer(videoElement, {
password: 'encryption-password'
});Electron 环境
Electron 环境通过 contextBridge 暴露的 API 与主进程通信,支持边解密边播放:
- 文件访问:通过 IPC 调用主进程的 Node.js API
- 网络请求:使用渲染进程的
fetchAPI - 加密解密:主进程使用 Node.js
crypto模块,渲染进程使用 Web Crypto - 播放:使用自定义协议(
emp4video://)实现边解密边播放
主进程设置
// main/index.ts
import { app, BrowserWindow, ipcMain, protocol } from 'electron';
import { EncryptorService } from './encryptor-service';
const encryptor = new EncryptorService();
// 注册自定义协议
protocol.registerSchemesAsPrivileged([
{
scheme: 'emp4video',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true
}
}
]);
// 暴露 API 给渲染进程
ipcMain.handle('player:load', async (_, filePath) => {
return encryptor.loadEMP4File(filePath);
});
ipcMain.handle('player:decryptToTempFast', async (_, filePath) => {
return encryptor.decryptToTempFast(filePath);
});
ipcMain.handle('player:continueDecrypt', async (_, decryptId) => {
return encryptor.continueDecrypt(decryptId);
});Preload 脚本
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
file: {
openDialog: () => ipcRenderer.invoke('file:openDialog'),
validate: (filePath: string) => ipcRenderer.invoke('file:validate', filePath),
getInfo: (filePath: string) => ipcRenderer.invoke('file:getInfo', filePath)
},
player: {
load: (filePath: string) => ipcRenderer.invoke('player:load', filePath),
decryptToTempFast: (filePath: string) =>
ipcRenderer.invoke('player:decryptToTempFast', filePath),
continueDecrypt: (decryptId: string) =>
ipcRenderer.invoke('player:continueDecrypt', decryptId),
onDecryptProgress: (callback: (progress: any) => void) => {
const handler = (_: any, progress: any) => callback(progress);
ipcRenderer.on('player:decryptProgress', handler);
return () => ipcRenderer.removeListener('player:decryptProgress', handler);
},
cleanupTemp: (tempPath: string) =>
ipcRenderer.invoke('player:cleanupTemp', tempPath),
checkPin: (filePath: string) => ipcRenderer.invoke('player:checkPin', filePath),
verifyPin: (filePath: string, pin: string) =>
ipcRenderer.invoke('player:verifyPin', filePath, pin)
},
passwordManager: {
savePassword: (filePath: string, pin: string) =>
ipcRenderer.invoke('password:save', filePath, pin),
getPassword: (filePath: string) => ipcRenderer.invoke('password:get', filePath),
deletePassword: (filePath: string) => ipcRenderer.invoke('password:delete', filePath)
}
});渲染进程使用
import { ElectronAdapter, ElectronEMP4Player } from '@eplayer/epl';
// 自动检测 Electron 环境
const { adapter } = detectEnvironment(true);
// 或手动创建适配器
const electronAdapter = new ElectronAdapter(true);
await electronAdapter.initialize();
// 使用 Electron 专属的边解密边播放功能
const player = new ElectronEMP4Player(
videoElement,
adapter.emp4PlayerFactory,
{ password: 'encryption-password' }
);
await player.load('/path/to/video.emp4');
// 播放器会自动:
// 1. 在主进程开始后台解密
// 2. 返回临时文件路径
// 3. 通过 emp4video:// 协议边解密边播放手动创建适配器
import { createAdapter } from '@eplayer/epl';
// 创建指定平台的适配器
const browserAdapter = createAdapter('browser', true);
const electronAdapter = createAdapter('electron', true);
// 使用适配器
await adapter.initialize();
// 文件操作
const exists = await adapter.fileSystem.fileExists('/path/to/file');
const info = await adapter.fileSystem.getFileInfo('/path/to/file');
const data = await adapter.fileSystem.readFile('/path/to/file');
// 网络请求
const manifest = await adapter.network.loadManifest('https://example.com/manifest.epl');
// 加密操作
const decrypted = await adapter.crypto.decryptEMP4(encryptedData, {
password: 'password'
});
// PIN 验证
const result = await adapter.crypto.verifyPin('1234', salt, hash);
// 销毁
adapter.destroy();跨平台工具函数
import {
isNode,
isBrowser,
isElectron,
isElectronRenderer,
getCrypto,
getPerformance,
base64ToUint8Array,
uint8ArrayToBase64,
arrayBufferToBase64,
base64ToArrayBuffer,
getCPUCores,
isWorkerSupported,
isXHRSupported,
isFetchSupported,
isWebCryptoSupported,
isMSESupported,
isVideoElementSupported,
isMediaPlaybackSupported,
getEnvironmentInfo
} from '@eplayer/epl';
// 环境检测
if (isElectron) {
console.log('运行在 Electron 环境');
}
// 获取 Web Crypto API(跨平台)
const crypto = getCrypto();
// Base64 编解码
const bytes = base64ToUint8Array('SGVsbG8=');
const base64 = uint8ArrayToBase64(bytes);
// 特性检测
if (isMSESupported()) {
// 可以使用 MSE 播放
}
if (isWorkerSupported()) {
// 可以使用 Web Worker 解密
}播放器插件
DPlayer 插件
import { EPLDPlayerPlugin } from '@eplayer/epl/dplayer-plugin';
const { dp, epl } = EPLDPlayerPlugin.createDPlayer({
dplayer: {
container: document.getElementById('dplayer'),
autoplay: true,
theme: '#00d4aa',
screenshot: true
},
epl: {
url: 'https://example.com/video.manifest.epl',
keyServer: 'https://key-server.example.com',
debug: true
}
});
// 监听事件
epl.onLevelSwitched = (data) => {
console.log('画质切换:', data.label);
};Video.js 插件
import videojs from 'video.js';
import { EPLVideoJSPlugin } from '@eplayer/epl/videojs-plugin';
// 注册插件
EPLVideoJSPlugin.register(videojs);
// 创建播放器
const player = videojs('my-video', {
controls: true,
autoplay: false,
preload: 'auto'
});
// 使用 EPL
player.EPL({
url: 'https://example.com/video.manifest.epl',
keyServer: 'https://key-server.example.com',
autoplay: true,
debug: true
});
// 监听画质切换
player.on('qualitychange', (event) => {
console.log('画质切换:', event.label);
});原生 Video 插件
import { EPLVideoPlugin } from '@eplayer/epl/video-plugin';
const manager = new EPLVideoPlugin.EPLVideoManager({
video: document.getElementById('video-player'),
container: document.getElementById('player-container'),
qualitySelect: document.getElementById('quality-select'),
epl: {
keyServer: 'https://key-server.example.com',
autoplay: true,
debug: true
},
onQualityChange: (data) => {
console.log('画质切换:', data.label);
},
onManifestLoaded: (data) => {
console.log('清单加载完成:', data.manifest.title);
}
});
// 加载视频源
manager.loadSource('https://example.com/video.manifest.epl');
// 播放本地文件
manager.playLocalFile(file);
// Electron 环境:播放本地 EMP4 文件
manager.playEMP4FileFromPath('/path/to/video.emp4', {
password: 'encryption-password'
});
// 获取播放状态
const bandwidth = manager.getBandwidthEstimate();
const buffered = manager.getForwardBufferedLength();
const levels = manager.getLevels();
const currentLevel = manager.getCurrentLevel();拖拽播放
EPL 插件内置拖拽支持,用户可以直接将文件拖入播放器容器进行播放。
支持的拖拽文件类型
| 文件类型 | 扩展名 | 说明 |
|---------|-------|------|
| EPL 清单 | .epl | EPL 流媒体清单文件 |
| EMP4 加密视频 | .emp4 | 加密的视频文件 |
| 普通视频 | .mp4, .webm, .ogg, .mov, .mkv, .avi, .flv, .m4v, .ts, .f4v, .3gp | 标准视频格式 |
使用 Manager 自动启用拖拽
所有 Manager 类都内置了拖拽支持:
import { EPLVideoPlugin } from '@eplayer/epl/video-plugin';
const manager = new EPLVideoPlugin.EPLVideoManager({
video: document.getElementById('video-player'),
container: document.getElementById('player-container'),
epl: { debug: true }
});
// 启用拖拽支持
manager.setupDragDrop(document.getElementById('player-container'));
// 用户拖入文件后,Manager 会自动:
// - 识别文件类型
// - 调用对应的播放方法(playLocalFile / playEMP4File / playEPLFile)
// - 显示日志信息拖拽样式
拖拽过程中会自动添加 drag-over CSS 类,可用于显示拖拽提示:
.player-container {
position: relative;
}
.player-container.drag-over::after {
content: '释放文件以播放';
position: absolute;
inset: 0;
background: rgba(0, 212, 170, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
z-index: 1000;
}PIN 码保护
EPL 支持 PIN 码保护机制,用于限制视频访问:
PIN 验证流程
用户输入 PIN
↓
PBKDF2-SHA256 派生哈希(100000 次迭代)
↓
与存储的哈希值比较(恒定时间比较)
↓
验证通过 → 使用 PIN 派生 AES-256 密钥
↓
解密视频内容使用示例
const player = new EPL(videoElement, {
pinSalt: 'Base64编码的盐值',
pinHash: 'Base64编码的哈希值'
});
// 检查是否需要 PIN
if (player.isPinProtected()) {
// 显示 PIN 输入对话框
const pin = await showPinDialog();
// 验证 PIN
const saltBuffer = base64ToUint8Array(player.config.pinSalt);
const hashBuffer = base64ToUint8Array(player.config.pinHash);
const result = await player.verifyPin(pin, saltBuffer, hashBuffer);
if (result.valid) {
// PIN 正确,派生解密密钥
await player.deriveKeyFromPin(pin, saltBuffer);
player.startLoad();
} else {
// PIN 错误
console.error(result.errorMessage);
}
}PIN 相关事件
player.on(EPL.Events.PIN_REQUIRED, (event, data) => {
// 需要输入 PIN
console.log('PIN 保护:', data.pinSalt, data.pinHash);
});
player.on(EPL.Events.PIN_VERIFYING, (event, data) => {
// 正在验证 PIN
console.log('验证中...');
});
player.on(EPL.Events.PIN_VERIFIED, (event, data) => {
// PIN 验证成功
console.log('验证成功');
});
player.on(EPL.Events.PIN_ERROR, (event, data) => {
// PIN 验证失败
console.error('验证失败:', data.message);
});生成 PIN 配置
import { PinVerifier } from '@eplayer/epl';
const verifier = new PinVerifier();
// 生成 PIN 盐值和哈希
const { salt, hash } = await verifier.generatePinHash('1234');
// 存储到清单或配置中
const pinSalt = uint8ArrayToBase64(salt);
const pinHash = uint8ArrayToBase64(hash);
console.log('pinSalt:', pinSalt);
console.log('pinHash:', pinHash);密钥管理
三种密钥模式
1. 密钥服务器模式
const player = new EPL(videoElement, {
keyServer: 'https://key-server.example.com/api/keys',
authToken: 'Bearer your-token',
deviceId: 'device-uuid'
});
// 密钥服务器 API 格式
// GET /api/keys?keyId=xxx&videoId=xxx
// Response: { key: "base64-key", iv: "base64-iv", expiresAt: timestamp }2. 密码派生模式
const player = new EPL(videoElement, {
password: 'encryption-password'
});
// 使用 PBKDF2-SHA256 从密码派生密钥
// 盐值来自 EMP4 文件头
// 迭代次数: 100000
// 密钥长度: 256 位3. PIN 码模式
const player = new EPL(videoElement, {
pin: '1234',
pinSalt: 'Base64编码的盐值',
pinHash: 'Base64编码的哈希值'
});
// PIN 验证通过后,使用 PIN 作为密码派生密钥密钥缓存
import { KeyManager } from '@eplayer/epl';
const keyManager = new KeyManager(logger, {
keyServer: 'https://key-server.example.com',
authToken: 'your-token'
});
// 获取密钥(自动缓存)
const cachedKey = await keyManager.getKey('key-id', 'video-id');
// 检查缓存
if (keyManager.hasKey('key-id')) {
// 密钥已缓存
}
// 清除缓存
keyManager.clearKey('key-id');
keyManager.clearAllKeys();API
EPL 主类
| 方法 / 属性 | 说明 |
|---|---|
| loadSource(url) | 加载 EPL 清单文件 |
| attachMedia() | 将 MediaSource 附加到视频元素 |
| detachMedia() | 分离 MediaSource |
| startLoad(position?) | 开始加载视频数据 |
| stopLoad() | 停止加载 |
| seekTo(position) | 跳转到指定位置(秒) |
| destroy() | 销毁播放器实例 |
| levels | 可用的画质级别列表 |
| currentLevel | 当前画质级别索引(-1 为自动) |
| nextLevel | 下一个要加载的画质级别 |
| loadLevel | 正在加载的画质级别 |
| bandwidthEstimate | 当前带宽估计值(bps) |
| bufferedLength | 当前缓冲区长度(秒) |
| forwardBufferedLength | 前向缓冲长度(秒) |
| bufferedRanges | 缓冲区时间范围数组 |
| manifest | 已加载的清单数据 |
| config | 当前配置对象 |
| verifyPin(pin, salt, hash) | 验证 PIN 码 |
| deriveKeyFromPin(pin, salt) | 从 PIN 码派生解密密钥 |
| isPinProtected() | 是否需要 PIN 验证 |
| updatePinConfig(pin, salt, hash) | 更新 PIN 配置 |
事件
// 清单事件
player.on(EPL.Events.MANIFEST_LOADING, callback); // 开始加载
player.on(EPL.Events.MANIFEST_LOADED, callback); // 加载完成
player.on(EPL.Events.MANIFEST_PARSED, callback); // 解析完成
player.on(EPL.Events.MANIFEST_ERROR, callback); // 加载/解析错误
// 密钥事件
player.on(EPL.Events.KEY_LOADING, callback); // 开始加载密钥
player.on(EPL.Events.KEY_LOADED, callback); // 密钥加载完成
player.on(EPL.Events.KEY_ERROR, callback); // 密钥加载错误
// PIN 事件
player.on(EPL.Events.PIN_VERIFYING, callback); // 正在验证 PIN
player.on(EPL.Events.PIN_VERIFIED, callback); // PIN 验证成功
player.on(EPL.Events.PIN_ERROR, callback); // PIN 验证失败
player.on(EPL.Events.PIN_REQUIRED, callback); // 需要 PIN 码
// 分片事件
player.on(EPL.Events.FRAG_LOADING, callback); // 开始加载分片
player.on(EPL.Events.FRAG_LOAD_PROGRESS, callback);// 加载进度
player.on(EPL.Events.FRAG_LOADED, callback); // 分片加载完成
player.on(EPL.Events.FRAG_DECRYPTED, callback); // 分片解密完成
player.on(EPL.Events.FRAG_PARSING, callback); // 开始解析
player.on(EPL.Events.FRAG_PARSED, callback); // 解析完成
player.on(EPL.Events.FRAG_ERROR, callback); // 分片错误
// 缓冲区事件
player.on(EPL.Events.BUFFER_CREATED, callback); // 缓冲区创建
player.on(EPL.Events.BUFFER_APPENDING, callback); // 正在追加数据
player.on(EPL.Events.BUFFER_APPENDED, callback); // 追加完成
player.on(EPL.Events.BUFFER_EOS, callback); // 流结束
player.on(EPL.Events.BUFFER_ERROR, callback); // 缓冲区错误
// 画质事件
player.on(EPL.Events.LEVEL_SWITCHING, callback); // 正在切换
player.on(EPL.Events.LEVEL_SWITCHED, callback); // 切换完成
player.on(EPL.Events.LEVEL_LOADING, callback); // 开始加载级别
player.on(EPL.Events.LEVEL_LOADED, callback); // 级别加载完成
player.on(EPL.Events.LEVEL_ERROR, callback); // 级别错误
// 媒体事件
player.on(EPL.Events.ATTACHING, callback); // 正在附加
player.on(EPL.Events.ATTACHED, callback); // 已附加
player.on(EPL.Events.DETACHING, callback); // 正在分离
player.on(EPL.Events.DETACHED, callback); // 已分离
// 生命周期事件
player.on(EPL.Events.ERROR, callback); // 错误(统一入口)
player.on(EPL.Events.DESTROYING, callback); // 正在销毁
player.on(EPL.Events.DESTROYED, callback); // 已销毁错误类型
// 错误大类
EPL.ErrorTypes.NETWORK_ERROR // 网络错误
EPL.ErrorTypes.MEDIA_ERROR // 媒体错误
EPL.ErrorTypes.KEY_ERROR // 密钥错误
EPL.ErrorTypes.DECRYPT_ERROR // 解密错误
EPL.ErrorTypes.MANIFEST_ERROR // 清单错误
EPL.ErrorTypes.LEVEL_ERROR // 画质级别错误
EPL.ErrorTypes.OTHER_ERROR // 其他错误
// 错误详情
EPL.ErrorDetails.MANIFEST_LOAD_ERROR // 清单加载失败
EPL.ErrorDetails.MANIFEST_LOAD_TIMEOUT // 清单加载超时
EPL.ErrorDetails.MANIFEST_PARSING_ERROR // 清单解析失败
EPL.ErrorDetails.LEVEL_LOAD_ERROR // 画质级别加载失败
EPL.ErrorDetails.LEVEL_LOAD_TIMEOUT // 画质级别加载超时
EPL.ErrorDetails.FRAG_LOAD_ERROR // 分片加载失败
EPL.ErrorDetails.FRAG_LOAD_TIMEOUT // 分片加载超时
EPL.ErrorDetails.KEY_LOAD_ERROR // 密钥加载失败
EPL.ErrorDetails.KEY_LOAD_TIMEOUT // 密钥加载超时
EPL.ErrorDetails.KEY_SYSTEM_ERROR // 密钥系统错误
EPL.ErrorDetails.PIN_VERIFY_ERROR // PIN 验证失败
EPL.ErrorDetails.PIN_REQUIRED // PIN 未提供
EPL.ErrorDetails.PIN_KEY_DERIVE_ERROR // PIN 密钥派生失败
EPL.ErrorDetails.DECRYPT_ERROR // 解密失败
EPL.ErrorDetails.BUFFER_ERROR // 缓冲区错误
EPL.ErrorDetails.BUFFER_APPEND_ERROR // 缓冲区追加失败
EPL.ErrorDetails.BUFFER_ADD_CODEC_ERROR // 编解码器添加失败
EPL.ErrorDetails.BUFFER_FULL_ERROR // 缓冲区已满错误处理
player.on(EPL.Events.ERROR, (event, data) => {
console.log('错误类型:', data.type);
console.log('错误详情:', data.details);
console.log('是否致命:', data.fatal);
if (data.fatal) {
switch (data.type) {
case EPL.ErrorTypes.NETWORK_ERROR:
// 网络错误,尝试重新加载
player.startLoad();
break;
case EPL.ErrorTypes.MEDIA_ERROR:
// 媒体错误,尝试恢复
// ...
break;
default:
// 其他致命错误,销毁播放器
player.destroy();
break;
}
}
});配置项
interface EPLConfig {
// 密钥服务器配置
keyServer?: string; // 密钥服务器 URL
authToken?: string; // 认证令牌
deviceId?: string; // 设备标识符
password?: string; // 解密密码
pin?: string; // PIN 码
pinSalt?: string; // PIN 码盐值(Base64)
pinHash?: string; // PIN 码哈希值(Base64)
pbkdf2Iterations?: number; // PBKDF2 迭代次数,默认 100000
// 缓冲区配置
maxBufferLength?: number; // 最大缓冲时长(秒),默认 30
maxMaxBufferLength?: number; // 绝对最大缓冲时长(秒),默认 600
maxBufferSize?: number; // 最大缓冲区大小(字节),默认 50MB
maxBufferHole?: number; // 允许的最大缓冲空洞(秒),默认 0.5
// ABR 配置
startLevel?: number; // 起始画质级别,-1 自动选择
capLevelToPlayerSize?: boolean; // 是否根据播放器尺寸限制画质
abrEwmaDefaultEstimate?: number; // 默认带宽估计值(bps),默认 500000
abrBandWidthFactor?: number; // 带宽安全系数,默认 0.95
abrBandWidthUpFactor?: number;// 码率升级因子,默认 0.7
abrMaxWithRealBitrate?: boolean; // 是否使用真实码率
// 自定义加载器
fragmentLoader?: any; // 自定义分片加载器
keyLoader?: any; // 自定义密钥加载器
// 调试和 Worker 配置
debug?: boolean; // 调试模式
enableWorker?: boolean; // 启用 Web Worker 解密
workerPath?: string; // Worker 脚本路径
// 网络请求回调
xhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
fetchSetup?: (context: any, initParams: any) => Request;
// 本地文件模式配置
localMode?: boolean; // 是否为本地文件模式
localBasePath?: string; // 本地文件基础路径
customProtocolHandler?: (url: string) => Promise<ArrayBuffer>;
}EMP4 文件格式
EMP4 是自定义的加密容器格式:
┌──────────┬──────────┬─────────────────────────────────────┐
│ 偏移量 │ 大小 │ 描述 │
├──────────┼──────────┼─────────────────────────────────────┤
│ 0 │ 4 │ 魔数 "EMP4" │
│ 4 │ 8 │ 版本号(如 "1.0.0") │
│ 12 │ 4 │ 头部大小(128) │
│ 16 │ 1 │ 加密算法(0x00 = AES-256-GCM) │
│ 17 │ 1 │ 密钥派生算法(0x00 = PBKDF2-SHA256)│
│ 18 │ 32 │ 盐值(用于 PBKDF2) │
│ 50 │ 12 │ 基础初始化向量 │
│ 62 │ 4 │ 原始格式(如 "mp4 ") │
│ 66 │ 8 │ 原始文件大小(解密后) │
│ 74 │ 32 │ 校验和 │
│ 106 │ 4 │ 元数据长度 │
│ 110 │ 18 │ 保留字段 │
├──────────┼──────────┼─────────────────────────────────────┤
│ 128 │ N │ JSON 元数据区 │
├──────────┼──────────┼─────────────────────────────────────┤
│ 128+N │ 变长 │ 加密数据块区 │
│ │ │ 每块: 4字节长度 + 密文 + 16字节标签 │
├──────────┼──────────┼─────────────────────────────────────┤
│ 末尾-12 │ 12 │ 文件尾校验区 │
└──────────┴──────────┴─────────────────────────────────────┘元数据格式
元数据区为 JSON 格式,可包含:
{
"title": "视频标题",
"duration": 600,
"hasPin": true,
"pinSalt": "Base64编码的PIN盐值",
"pinHash": "Base64编码的PIN哈希"
}EMP4 解析
import { EMP4Parser } from '@eplayer/epl';
const parser = new EMP4Parser();
// 解析文件头
const header = parser.parseHeader(arrayBuffer);
console.log('版本:', header.version);
console.log('原始格式:', header.format);
console.log('原始大小:', header.originalSize);
// 解析完整文件
const { header, metadata, chunks } = parser.parse(arrayBuffer);
console.log('元数据:', metadata);
console.log('数据块数:', chunks.length);
// 验证文件
const valid = parser.validate(arrayBuffer);EMP4 双层加密模型
EMP4 文件采用双层独立加密架构:解密密钥层负责实际 AES-GCM 解密,PIN 验证层(可选)负责身份认证。两层互不干扰。
架构总览
┌─────────────────────────────────────────────────────────────┐
│ EMP4 双层加密模型 │
├──────────────────────────┬──────────────────────────────────┤
│ 第 1 层:解密密钥层 │ 第 2 层:PIN 验证层(可选) │
│ │ │
│ decryptionPassword │ 用户输入的 PIN 码 │
│ (默认 'default- │ │
│ encryption-key') │ │
│ ↓ │ ↓ │
│ PBKDF2-SHA256 │ PBKDF2-SHA256 │
│ (salt = 文件头盐值) │ (salt = 元数据 pinSalt) │
│ (100000 次迭代) │ (100000 次迭代) │
│ ↓ │ ↓ │
│ AES-256-GCM 密钥 │ hash 值 │
│ ↓ │ ↓ │
│ ┌─────────────┐ │ 与元数据 pinHash │
│ │ AES-GCM 解密 │◄──────┼── 恒定时间比较 │
│ │ 每个 chunk │ │ ↓ │
│ │ IV = baseIV +│ │ ✅ 通过 → 身份验证成功 │
│ │ chunkIndex │ │ ❌ 失败 → 拒绝访问 │
│ └─────────────┘ │ │
│ ↓ │ ⚠️ 注意:PIN 不参与解密! │
│ 解密后的 MP4 数据 │ 解密始终使用 decryptionPassword │
└──────────────────────────┴──────────────────────────────────┘
文件结构:
┌──────────────────────────────────────────────────┐
│ EMP4 文件头 (128 字节) │
│ ├── magic: "EMP4" │
│ ├── salt: [32 bytes] ← 第1层 PBKDF2 输入 │
│ ├── iv: [12 bytes] ← AES-GCM 基础向量 │
│ └── metadataLength: N │
├──────────────────────────────────────────────────┤
│ JSON 元数据区 (N 字节) ← 第2层 PIN 信息存储于此 │
│ { │
│ pinSalt: "Base64...", ← PIN PBKDF2 盐值 │
│ pinHash: "Base64...", ← PIN PBKDF2 哈希 │
│ title: "...", │
│ hasPin: true │
│ } │
├──────────────────────────────────────────────────┤
│ 加密数据块区 │
│ [chunk_0: 4B长度 + 密文 + 16B GCM标签] │
│ [chunk_1: 4B长度 + 密文 + 16B GCM标签] │
│ [...] │
└──────────────────────────────────────────────────┘无 PIN 的 EMP4 文件播放流程(浏览器)
适用于没有设置 PIN 保护的 EMP4 文件(元数据中无 pinSalt / pinHash):
用户拖入 .emp4 文件
│
▼
┌───────────────────────────────┐
│ BaseManager.playEMP4File(file) │
│ ① cleanup() 清理旧资源 │
│ ② 创建 video 元素 │
│ ③ new EMP4Player(video) │
│ → decryptionPassword = │
│ 'default-encryption-key' │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ emp4Player.load(file) │
│ ① 读取 File → ArrayBuffer │
│ ② EMP4Parser.parse() 解析: │
│ - header (salt, iv, ...) │
│ - metadata (无 PIN 信息) │
│ - chunks[] │
│ ③ extractPinInfo(metadata) │
│ → hasPin = false │
│ → 跳过 PIN 验证 │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ DecryptEngine │
│ setPassword( │
│ 'default-encryption-key' │
│ ) │
│ ↓ │
│ decrypt(data) │
│ ├─ resolveCryptoKey() │
│ │ └─ deriveKeyFromPassword() │
│ │ TextEncoder('default- │
│ │ encryption-key') │
│ │ → PBKDF2(salt, 100k) │
│ │ → importKey(AES-GCM) │
│ ├─ parseHeader() │
│ ├─ for each chunk: │
│ │ ├─ generateChunkIV(iv,i) │
│ │ ├─ prepareEncryptedData() │
│ │ └─ crypto.subtle.decrypt() │
│ └─ concatArrayBuffers() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ 播放 │
│ Blob([decrypted], 'video/mp4') │
│ URL.createObjectURL() → src │
│ emit('ready') │
│ video.play() │
│ decryptEngine.clearKey() 安全擦除│
└───────────────────────────────┘有 PIN 的 EMP4 文件播放流程(浏览器)
适用于设置了 PIN 保护的 EMP4 文件(元数据中有 pinSalt / pinHash):
用户拖入 .emp4 文件
│
▼
┌───────────────────────────────┐
│ BaseManager.playEMP4File(file) │
│ ① cleanup() 清理旧资源 │
│ ② 创建 video 元素 │
│ ③ new EMP4Player(video) │
│ → decryptionPassword = │
│ 'default-encryption-key' │
│ → pin = null (尚未提供) │
│ ④ 绑定事件监听: │
│ - on('ready') → 播放 │
│ - on('pinrequired') → 弹框 │
│ - on('error') → 显示密码框 │
│ ⑤ emp4Player.load(file) │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ emp4Player.load() — 首次加载 │
│ ① 读取 File → ArrayBuffer │
│ ② EMP4Parser.parse() │
│ header + metadata + chunks │
│ ③ extractPinInfo(metadata) │
│ → hasPin = true ✅ │
│ → salt = metadata.pinSalt │
│ → hash = metadata.pinHash │
│ ④ this.pin === null? │
│ → YES! 还没输入 PIN │
│ → emit('pinrequired', { │
│ pinSalt, pinHash, │
│ message: '需要PIN码' │
│ }) │
│ → return (不解密,等待输入) │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ showPinDialog() 弹出 PIN 对话框 │
│ │
│ ┌─────────────────────────┐ │
│ │ 🔒 此视频需要 PIN 码 │ │
│ │ │ │
│ │ [请输入 PIN 码_______] │ │
│ │ │ │
│ │ [取消] [确认] │ │
│ └─────────────────────────┘ │
│ │
│ 用户输入 PIN: "123456" │
│ 点击 [确认] │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ verifyPasswordAndPlay("123456")│
│ │
│ ① 销毁旧的 emp4Player │
│ ② new EMP4Player(video, { │
│ pin: "123456", │
│ pinSalt: 从元数据获取, │
│ pinHash: 从元数据获取, │
│ }) │
│ → decryptionPassword = │
│ 'default-encryption-key' │
│ → pin = "123456" │
│ ③ emp4Player.load(file) 再次加载│
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ emp4Player.load() — 二次加载 │
│ ① 解析文件 (同上) │
│ ② extractPinInfo(metadata) │
│ → hasPin = true │
│ ③ this.pin !== null? │
│ → YES! 已有 PIN │
│ ④ PinVerifier.verifyPin( │
│ "123456", │
│ base64ToUint8Array(salt), │
│ base64ToUint8Array(hash) │
│ ) │
│ ├─ hashPin(PIN, pinSalt) │
│ │ → PBKDF2(100000次) │
│ └─ constantTimeCompare() │
│ computed vs stored │
│ │
│ → valid? │
│ ├─ ✅ YES → 继续 │
│ └─ ❌ NO → throw Error │
│ "PIN 码错误" │
└──────────────┬────────────────┘
│ ✅ PIN 验证通过
▼
┌───────────────────────────────┐
│ DecryptEngine — 解密(与无PIN相同)│
│ │
│ setPassword( │
│ 'default-encryption-key' │
│ ) ← 注意:用解密密码,不是 PIN! │
│ │
│ decrypt(data) │
│ ├─ PBKDF2('default-enc...', │
│ │ header.salt) → AES key │
│ ├─ 对每个 chunk: │
│ │ AES-GCM decrypt(chunkIV) │
│ └→ 完整的 MP4 ArrayBuffer │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ 播放 │
│ Blob → ObjectURL → video.src │
│ emit('ready', {header, metadata})│
│ video.play() │
│ decryptEngine.clearKey() 安全擦除 │
└───────────────────────────────┘关键设计原则
| 原则 | 说明 |
|------|------|
| 职责分离 | PIN 只做身份验证(hash 对比),不做密钥派生 |
| 解密密码固定 | 所有 EMP4 文件使用相同的 decryptionPassword 派生 AES 密钥 |
| 延迟验证 | 首次 load() 自动检测 PIN 需求,无需提前配置 |
| 安全擦除 | 解密完成后立即调用 clearKey() 清除内存中的密钥材料 |
| 向后兼容 | 无 PIN 的文件流程不受影响,自动跳过 PIN 验证步骤 |
EMP4Player 配置接口
interface EMP4PlayerConfig {
/** 解密密码(PBKDF2 输入,默认 'default-encryption-key') */
decryptionPassword?: string;
/** PIN 码(用于身份验证,从元数据 pinSalt/pinHash 派生) */
pin?: string;
/** PIN 盐值(Base64,通常从 EMP4 元数据中自动提取) */
pinSalt?: string;
/** PIN 哈希值(Base64,通常从 EMP4 元数据中自动提取) */
pinHash?: string;
}
// 用法示例:
// 1. 无 PIN 文件 — 直接播放
const player = new EMP4Player(video); // 使用默认 decryptionPassword
await player.load(emp4File);
// 2. 有 PIN 文件 — 两步式
const player = new EMP4Player(video);
player.on('pinrequired', (data) => {
// 收集用户 PIN 后重新加载
player.setPin(userPin, data.pinSalt, data.pinHash);
player.load(emp4File); // 此次会先验证 PIN 再解密
});
await player.load(emp4File);
// 3. 有 PIN 文件 — 一步式(已知 PIN 时)
const player = new EMP4Player(video, {
pin: '123456',
pinSalt: '从某处获取',
pinHash: '从某处获取'
});
await player.load(emp4File); // 自动验证 PIN 并解密EPL 清单格式
EPL 清单为 JSON 格式:
{
"version": "1.0",
"type": "vod",
"url": "https://example.com/video.manifest.epl",
"duration": 600,
"title": "示例视频",
"streams": [
{
"name": "1080p",
"bandwidth": 8000000,
"width": 1920,
"height": 1080,
"resolution": "1920x1080",
"codecs": "avc1.42E01E,mp4a.40.2",
"segments": [
{ "uri": "streams/1080p/segment-0.emp4", "duration": 10, "size": 10485760 },
{ "uri": "streams/1080p/segment-1.emp4", "duration": 10, "size": 10485760 }
]
},
{
"name": "720p",
"bandwidth": 5000000,
"width": 1280,
"height": 720,
"resolution": "1280x720",
"codecs": "avc1.42E01E,mp4a.40.2",
"segments": [...]
}
],
"encryption": {
"method": "AES-256-GCM",
"keyId": "6c816e25414df8bc2a571c3504079f5e",
"keyServer": "https://key-server.example.com"
},
"metadata": {
"author": "作者",
"description": "描述"
},
"hasPin": false
}清单解析
import { EPLParser } from '@eplayer/epl';
const parser = new EPLParser();
// 解析清单
const { manifest, levels } = parser.parse(manifestJson, baseUrl);
console.log('时长:', manifest.duration);
console.log('画质数:', levels.length);
levels.forEach((level, index) => {
console.log(`画质 ${index}: ${level.name}, ${level.bandwidth}bps, ${level.segments.length} 分片`);
});架构
src/
├── core/ # 核心模块
│ ├── epl.ts # EPL 主类
│ ├── epl-config.ts # 配置管理
│ ├── epl-events.ts # 事件定义
│ └── epl-errors.ts # 错误定义
├── controller/ # 控制器
│ ├── stream-controller.ts # 流加载控制
│ ├── abr-controller.ts # 自适应码率控制
│ ├── key-controller.ts # 密钥管理控制
│ └── buffer-controller.ts # 缓冲区控制
├── crypto/ # 加密模块
│ ├── decrypt-engine.ts # AES-256-GCM 解密引擎
│ ├── key-manager.ts # 密钥缓存与管理
│ ├── pin-verifier.ts # PIN 码验证
│ └── workers/ # Web Worker 线程池
│ └── worker-pool.ts
├── parser/ # 解析器
│ ├── epl-parser.ts # EPL 清单解析
│ └── emp4-parser.ts # EMP4 文件解析
├── loader/ # 网络加载器
│ ├── xhr-loader.ts # XHR 请求封装
│ ├── manifest-loader.ts # 清单加载
│ ├── fragment-loader.ts # 分片加载
│ └── key-loader.ts # 密钥加载
├── mse/ # Media Source Extensions
│ ├── media-source.ts # MediaSource 控制
│ ├── mse-manager.ts # MSE 管理器
│ └── source-buffer.ts # SourceBuffer 封装
├── abr/ # 自适应码率
│ ├── bandwidth-estimator.ts # 带宽估计(EWMA 算法)
│ └── level-selector.ts # 画质选择策略
├── plugins/ # 播放器插件
│ ├── common.ts # 插件公共模块
│ ├── dplayer-plugin.ts # DPlayer 集成
│ ├── videojs-plugin.ts # Video.js 集成
│ └── video-plugin.ts # 原生 Video 集成
├── simple/ # 简化播放器
│ └── simple-player.ts # EPLSimplePlayer / EMP4Player / PlayerFactory
├── environment/ # 环境适配
│ ├── index.ts # 统一导出
│ ├── types.ts # 类型定义
│ ├── detector.ts # 环境检测
│ ├── browser.ts # 浏览器适配器
│ └── electron.ts # Electron 适配器 + ElectronEMP4Player
├── types/ # 类型定义
│ ├── config.d.ts # 配置类型
│ ├── events.d.ts # 事件类型
│ ├── fragment.d.ts # 分片类型
│ ├── level.d.ts # 画质级别类型
│ └── manifest.d.ts # 清单类型
└── utils/ # 工具函数
├── event-emitter.ts # 事件发射器
├── logger.ts # 日志记录
├── buffer.ts # ArrayBuffer 工具函数
├── url.ts # URL 解析与拼接
├── node-compat.ts # 跨平台兼容层
└── polyfill.ts # 特性检测与 polyfill构建
npm run build # 构建类型声明 + JS
npm run build:types # 仅构建类型声明
npm run build:js # 仅构建 JS(esbuild)
npm run dev # 监听模式构建
npm run typecheck # 类型检查
npm run lint # ESLint 检查输出格式:
| 入口 | UMD | ESM | CJS |
|---|---|---|---|
| @eplayer/epl | dist/epl.js | dist/epl.esm.js | dist/epl.cjs |
| @eplayer/epl/dplayer-plugin | dist/dplayer-plugin.js | dist/dplayer-plugin.esm.js | dist/dplayer-plugin.cjs |
| @eplayer/epl/videojs-plugin | dist/videojs-plugin.js | dist/videojs-plugin.esm.js | dist/videojs-plugin.cjs |
| @eplayer/epl/video-plugin | dist/video-plugin.js | dist/video-plugin.esm.js | dist/video-plugin.cjs |
测试
npm run server # 启动本地测试服务器 (http://localhost:3005)测试资源位于 test/ 目录,包含:
- 多分辨率加密视频流(480p / 720p / 1080p)
- EMP4 文件分析工具
- 密钥派生工具
- Python/Java 密钥服务器示例
- Electron 测试应用
License
MIT
