1.引言

在桌面应用开发中,本地视频的高效加载与播放是提升用户体验的关键。本文介绍如何通过 Electron 的协议处理器与 Node.js 流式 API,实现高性能的本地视频加载方案。

自定义协议流传输的优势

特性 文件路径 Blob 流式传输
内存占用 高(线性增长) 极高(全量驻留) 低(动态分片)
大文件支持
首帧时间
拖拽播放 卡顿 卡顿 流畅
内存泄漏风险

2. 核心实现原理

2.1 协议层架构设计

1
2
3
4
5
6
sequenceDiagram
Renderer->>Main: 请求 media://id.mp4
Main->>FileSystem: 创建可读流
FileSystem->>Main: 返回文件流
Main->>Renderer: 流式传输响应

2.2 关键技术选型

  • Electron Protocol API:注册自定义协议替代file://
  • Node.js Stream:实现分块读取与传输
  • HTTP Range:支持视频播放器范围请求

3. 技术实现

3.1 安全映射机制

在主进程中实现一个 map 将真实路径存放起来,当使用 electron dialog 选择文件后,可以生成一个 id 返回给 renderer 端,renderer 端通过 id 来获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const mediaFileMap = new Map<string, string>();

export const addMediaFile = (id: string, path: string) => {
mediaFileMap.set(id, path);
};

export const getMediaFile = (id: string) => {
return mediaFileMap.get(id);
};

export const removeMediaFile = (id: string) => {
mediaFileMap.delete(id);
};

export const clearMediaFileMap = () => {
mediaFileMap.clear();
};

这样做的好处有两点:

  • 避免暴露真实路径
  • 如果路径中有空格等特殊字符串,经过 encodeURIComponent 后,会传递不到给自定义的 Protocol

3.2 协议注册与配置

1
2
3
4
5
6
7
8
9
10
11
12
13
import { protocol } from 'electron';

protocol.registerSchemesAsPrivileged([
{
scheme: 'media', // 自定义协议名
privileges: {
standard: true, // 标准权限
secure: true, // 安全上下文
stream: true, // 支持流式传输
bypassCSP: true, // 绕过内容安全策略
},
},
]);

3.3 路径获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import path from 'path';
import { existsSync } from 'fs';
import { getMediaFile } from '../utils/store/media-file-map';

const getMediaFilePath = (reqUrl: string) => {
const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
// 渲染端通过传id.[ext]来获取对应的路径
const [id] = filePath.split('.');
const mediaFilePath = getMediaFile(id);

if (!mediaFilePath) {
return '';
}

const normalizedPath = path.normalize(mediaFilePath);

if (!existsSync(normalizedPath)) {
return '';
}

return normalizedPath;
};

3.4 获取 range

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { promises } from 'fs';

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
const { size } = await promises.stat(normalizedPath);
let start = 0,
end = size - 1;
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
start = match[1] ? parseInt(match[1], 10) : start;
end = match[2] ? parseInt(match[2], 10) : end;
}
}
const chunkSize = (end || size - 1) - start + 1;

return { start, end, chunkSize, size };
};

3.5 实现流式响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export const initMediaProtocol = () => {
protocol.handle('media', async (req) => {
try {
const normalizedPath = getMediaFilePath(req.url);

if (!normalizedPath) {
return new Response('File not found', { status: 404 });
}

const rangeHeader = req.headers.get('Range');

const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

const stream = createReadStream(normalizedPath, { start, end });
stream.on('error', (error) => {
stream.destroy();
return new Response(`Error: ${error.message}`, { status: 500 });
});
return new Response(stream as any, {
status: rangeHeader ? 206 : 200,
headers: {
'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': getContentType(normalizedPath),
},
});
} catch (error: any) {
console.error('Error handling media protocol:', error);
return new Response(`Error: ${error.message}`, { status: 500 });
}
});
};

initMediaProtocol这个方法要在 app when ready 后执行。

完整代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { protocol } from 'electron';
import path from 'path';
import { getMediaFile } from '../utils/store/media-file-map';
import { getContentType } from '../utils/content-type';
import { createReadStream, existsSync, promises } from 'fs';

protocol.registerSchemesAsPrivileged([
{
scheme: 'media',
privileges: {
standard: true, // 标准权限
secure: true, // 安全上下文
stream: true, // 支持流式传输
bypassCSP: true, // 绕过内容安全策略
},
},
]);

const getMediaFilePath = (reqUrl: string) => {
const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
const [id] = filePath.split('.');
const mediaFilePath = getMediaFile(id);

if (!mediaFilePath) {
return '';
}

const normalizedPath = path.normalize(mediaFilePath);

if (!existsSync(normalizedPath)) {
return '';
}

return normalizedPath;
};

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
const { size } = await promises.stat(normalizedPath);
let start = 0,
end = size - 1;
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
start = match[1] ? parseInt(match[1], 10) : start;
end = match[2] ? parseInt(match[2], 10) : end;
}
}
const chunkSize = (end || size - 1) - start + 1;

return { start, end, chunkSize, size };
};

export const initMediaProtocol = () => {
protocol.handle('media', async (req) => {
try {
const normalizedPath = getMediaFilePath(req.url);

if (!normalizedPath) {
return new Response('File not found', { status: 404 });
}

const rangeHeader = req.headers.get('Range');

const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

const stream = createReadStream(normalizedPath, { start, end });
stream.on('error', (error) => {
stream.destroy();
return new Response(`Error: ${error.message}`, { status: 500 });
});
return new Response(stream as any, {
status: rangeHeader ? 206 : 200,
headers: {
'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': getContentType(normalizedPath),
},
});
} catch (error: any) {
console.error('Error handling media protocol:', error);
return new Response(`Error: ${error.message}`, { status: 500 });
}
});
};

4. 渲染端应用

渲染端中只要拼接好对应协议即可

Pasted image 20250326143338.png

5. 效果

Pasted image 20250326143527.png
使用这种流式数据播放,能够随时拖动时间轴,每次也只会拿时间轴开始到一定范围的数据,减少内存占用。

6. 不足

如上图效果所示,缓冲区的 end 都是到视频最大的 size,这样不太合理。前端应该拿到 content-range 后将通过切分做进步的优化,比如分多段来获取缓存。