Electron 自定义协议与视频流式加载:从 moov atom 到 Range 请求的工程化实践
当
<video>标签遇到本地文件,你以为只是file://那么简单?在桌面端视频编辑工具里,这一行 src 背后藏着协议注册、Range 解析、206 Partial Content、MP4 moov atom seek、路径穿越防护、Node Stream 到 Web ReadableStream 的转换------任何一环失守,用户看到的就只有一个黑色的播放器和一句冰冷的 "Format error"。
一、为什么不能用 file://
在 Electron 桌面应用里加载本地视频,最直觉的方案是:
html
<video src="file:///C:/videos/demo.mp4" />
但这在生产环境几乎不可用:
- 安全策略冲突 :Electron 默认开启
webSecurity,file://协议在渲染进程里受严格同源限制,跨窗体、跨 iframe 引用频繁失败。 - Range 支持不稳定 :Chromium 对
file://的 Range 请求实现因平台而异,Windows 下seek 大文件时常出现"卡顿---跳帧---黑屏"。 - 路径暴露:绝对路径直接暴露在 DOM 里,既不优雅也不安全。
- 无法自定义鉴权:无法对资源访问加一层应用层校验。
业界标准做法是注册一个自定义协议 (如 app:// 或 media://),把本地资源访问收敛到主进程的 Node 层,由开发者完全掌控字节流的输出。
二、协议注册:privileged 时机与 handle 实现
Electron 提供两套 API:
protocol.registerSchemesAsPrivileged(schemes):必须在app.ready之前调用,声明协议的特权属性(支持 Range、stream、bypassCSP 等)。protocol.handle(scheme, handler):Electron 22+ 的新 API,替换已废弃的registerFileProtocol,返回Response对象,语义对齐 Fetch API。
关键陷阱:必须先声明特权,再 handle 。如果只 handle 不声明 privileged,Range 请求会被 Chromium 拦在协议层之外,<video> 永远拿不到 206。
ts
// main/protocol.ts
import { protocol } from 'electron'
const PROTOCOL = 'app-media'
export function registerMediaProtocol() {
// ① 必须在 app.ready 之前调用
protocol.registerSchemesAsPrivileged([{
scheme: PROTOCOL,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true, // ← 关键:允许流式响应
bypassCSP: false,
},
}])
// ② 在 app.whenReady() 之后注册 handler
app.whenReady().then(() => {
protocol.handle(PROTOCOL, handleMediaRequest)
})
}
stream: true 是视频能正常播放的前提------它告诉 Chromium "这个协议的响应体可能很大,请按流式处理,不要一次性 buffer 到内存"。
三、Range 请求:从 bytes=-N 说起
<video> 播放 MP4 时,Chromium 的媒体栈会发出至少三类请求:
- 初始化请求 :
Range: bytes=0-,期望拿到文件开头的ftypbox 和(理想情况下)moovbox。 - 末尾探针 :
Range: bytes=-N(N 通常是 1~2KB),用来读取文件末尾的某些元数据。 - seek 请求 :
Range: bytes=start-end,用户拖动进度条时的精准定位。
第 2 类是新手最容易忽略的。HTTP/1.1 规范里 bytes=-N 表示"取文件最后 N 个字节",等价于 bytes=(filesize-N)-(filesize-1)。如果你的 handler 只处理 bytes=start-end 这种两端都有的格式,<video> 就会卡在加载阶段,控制台报 "Format error" 或 PIPELINE_ERROR。
完整 Range 解析:
ts
function parseRange(rangeHeader: string, totalSize: number): { start: number; end: number } | null {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
const ranges = rangeHeader.slice(6).split(',')
if (ranges.length !== 1) return null // 视频场景不处理多段 Range
const r = ranges[0].trim()
const [startStr, endStr] = r.split('-')
let start: number
let end: number
if (startStr === '') {
// bytes=-N → 最后 N 字节
const suffixLength = parseInt(endStr, 10)
if (Number.isNaN(suffixLength) || suffixLength <= 0) return null
start = Math.max(0, totalSize - suffixLength)
end = totalSize - 1
} else {
start = parseInt(startStr, 10)
end = endStr === '' ? totalSize - 1 : parseInt(endStr, 10)
if (Number.isNaN(start) || start < 0 || start >= totalSize) return null
if (end >= totalSize) end = totalSize - 1
if (end < start) return null
}
return { start, end }
}
返回 206 时必须带三个头:
ts
const headers = new Headers({
'Content-Type': 'video/mp4',
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${totalSize}`,
'Content-Length': String(end - start + 1),
'Cache-Control': 'no-cache',
})
return new Response(readStream, { status: 206, headers })
Accept-Ranges: bytes 只需在首次响应里出现一次,但每次都带上更安全;Content-Range 的 totalSize 必须是完整文件大小,否则 Chromium 算不出总时长。
四、moov atom:MP4 的"目录"与 seek 的命门
MP4 文件由若干 box(atom)组成,其中 moov box 存储全部索引信息------每个帧在文件中的偏移、时间戳、关键帧位置。<video> 要支持 seek,必须先拿到 moov。
moov 的位置有两种:
- fast-start(moov 在前):编码时把 moov 移到文件头部,播放器一开始就能 seek,体验最佳。
- moov 在后 :编码器默认行为,moov 在文件末尾。播放器必须先发
bytes=-N探测末尾,拿到 moov 后才能正常播放。
这就解释了为什么前面强调 bytes=-N 必须正确处理------如果你的 handler 返回 200 而不是 206,或者 Content-Range 错误,Chromium 永远拿不到 moov,<video> 就只能播前几秒、不能 seek、甚至直接 Format error。
生产建议:渲染导出时用 FFmpeg 的 -movflags faststart 让 moov 前置;但在素材库场景下用户上传的 MP4 可能是 moov 在后,协议层必须同时支持两种。
五、路径穿越防护:parseLocalMediaUrl
自定义协议最大的风险是任意文件读取 。攻击者只要在网页里塞一个 <img src="app-media:///C:/Windows/System32/config/SAM"> 就能读系统文件。必须在协议层做白名单 + 路径规范化。
ts
import { resolve, normalize, isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
const MEDIA_ROOTS = [
resolve(app.getPath('userData'), 'media-cache'),
resolve(app.getPath('videos')),
]
function parseLocalMediaUrl(rawUrl: string): string | null {
let url: URL
try {
url = new URL(rawUrl)
} catch {
return null
}
// 协议层只允许 file: 或自定义 scheme
if (url.protocol !== 'file:') return null
let fsPath = decodeURIComponent(url.pathname)
// Windows 下 file URL 的 pathname 是 /C:/...,需要去掉前导 /
if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(fsPath)) {
fsPath = fsPath.slice(1)
}
if (!isAbsolute(fsPath)) return null
const normalized = normalize(fsPath)
// 必须落在某个白名单根目录下
const allowed = MEDIA_ROOTS.some(root =>
normalized === root || normalized.startsWith(root + sep)
)
if (!allowed) return null
return normalized
}
要点:
- 必须用
normalize抹平..、.、多余分隔符,否则media-cache/../../etc/passwd能绕过startsWith检查。 - Windows 路径前导
/的剥离是常见 bug 源------file:///C:/a.mp4的 pathname 是/C:/a.mp4,不解的话 Node 的createReadStream会直接 ENOENT。 - 白名单根目录最好动态生成(包含用户选的项目目录),而不是写死。
六、Node Stream → Web ReadableStream
protocol.handle 期望 handler 返回 Response,其 body 必须是 Web ReadableStream。但 Node 的 fs.createReadStream 返回的是 Node Stream,两者不兼容 。Electron 提供了 Readable.toWeb(Node 17+)可以直接转:
ts
import { createReadStream } from 'node:fs'
import { Readable } from 'node:stream'
function createRangeStream(filePath: string, start: number, end: number) {
const nodeStream = createReadStream(filePath, { start, end })
return Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>
}
转换后注意:
- Node Stream 的
error事件会被转发到 Web Stream 的cancel,但 Chromium 拿到的是空 body 而不是错误。生产里最好包一层TransformStream显式捕获 fs.error,转成Response500。 - 大文件(>2GB)在 32 位 Node 上
end超过Number.MAX_SAFE_INTEGER会溢出,必须用BigInt或限制单段大小。
七、handler 全貌
ts
async function handleMediaRequest(req: Request): Promise<Response> {
const fsPath = parseLocalMediaUrl(req.url)
if (!fsPath) {
return new Response('Forbidden', { status: 403 })
}
const stat = await fs.promises.stat(fsPath).catch(() => null)
if (!stat || !stat.isFile()) {
return new Response('Not Found', { status: 404 })
}
const totalSize = stat.size
const rangeHeader = req.headers.get('range')
if (!rangeHeader) {
// 整文件返回,仍要 206 而非 200(视频协议惯例)
const stream = createRangeStream(fsPath, 0, totalSize - 1)
return new Response(stream, {
status: 206,
headers: {
'Content-Type': mimeLookup(fsPath) ?? 'application/octet-stream',
'Accept-Ranges': 'bytes',
'Content-Range': `bytes 0-${totalSize - 1}/${totalSize}`,
'Content-Length': String(totalSize),
},
})
}
const range = parseRange(rangeHeader, totalSize)
if (!range) {
return new Response('Range Not Satisfiable', {
status: 416,
headers: { 'Content-Range': `bytes */${totalSize}` },
})
}
const { start, end } = range
const stream = createRangeStream(fsPath, start, end)
return new Response(stream, {
status: 206,
headers: {
'Content-Type': mimeLookup(fsPath) ?? 'application/octet-stream',
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${totalSize}`,
'Content-Length': String(end - start + 1),
'Cache-Control': 'no-cache',
},
})
}
注意 Cache-Control: no-cache:视频文件在编辑过程中会被覆盖写(导出、转码),如果命中 HTTP 缓存,会出现"明明替换了素材,预览却还是旧的"的灵异现象。
八、调试技巧
- 看实际请求:在渲染进程 DevTools 的 Network 面板勾选 "Disable cache",能看到所有 Range 请求的 status / headers。
- 抓主进程日志 :在 handler 里
console.log(req.url, rangeHeader, parsedRange),通过 Electron 主进程 stdout 观察。 - 测试 moov 位置 :
ffmpeg -v trace -i demo.mp4 2>&1 | grep -E "moov|mdat"可以看到 box 顺序。 - 白盒测试 :用
curl -r -1024 http://localhost:port/...直接发bytes=-1024请求,验证末尾 Range。
九、小结
桌面端视频加载的复杂度远超 Web 视频------你以为只是"读个文件",实际上要在协议层重新实现一遍 HTTP/1.1 Range 语义、MP4 容器格式感知、路径安全沙箱、Node/Web Stream 互转。这套方案的工程价值在于:
- 统一资源寻址 :所有视频素材都走
app-media://协议,渲染进程无需感知本地路径。 - 细粒度安全:路径白名单 + 规范化,杜绝任意文件读取。
- 流式性能:Range + 206 让 GB 级素材秒开,内存占用恒定。
- 可观测:所有请求经过主进程,便于埋点、缓存、断点续传扩展。
下一轮迭代可以考虑在此基础上加 HTTP/2 多路复用(Electron 已支持)、或把协议层升级为本地 HTTP 服务器以支持 Worker 协程并行 fetch------但那是另一个故事了。