npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@eplayer/epl

v0.0.3

Published

EPL/EMP4 streaming video player library

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 加载远程文件,或通过 File API 读取用户选择的本地文件
  • 网络请求:使用原生 fetch API
  • 加密解密:使用 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
  • 网络请求:使用渲染进程的 fetch API
  • 加密解密:主进程使用 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