下载引擎架构
FlowPick 的下载引擎是整个系统的核心,负责将流媒体分片下载、解密、合并并写入磁盘。本文档面向希望深入理解内部机制的开发者和高级用户。

架构概览
下载引擎由三个核心模块组成,数据按流水线方式依次流经各模块:
┌─────────────────────────────────────────────────┐
│ 下载引擎 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 分片下载 │→│ 分片处理 │→│ 文件写入 │ │
│ │ 模块 │ │ 模块 │ │ 模块 │ │
│ │ │ │ │ │ │ │
│ │ · 并发 │ │ · 解密 │ │ · FSA 流式 │ │
│ │ · 重试 │ │ · 拼接 │ │ · StreamSaver │ │
│ │ · 限速 │ │ · 转封装 │ │ · Blob 兜底 │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 内存安全管理器 │ │
│ │ · 大小预估 · 阈值检查 · 策略选择 │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
数据流全景
从用户点击下载到文件写入磁盘的完整链路:
用户点击下载
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. 清单解析 │ ──→ │ 2. 分片下载 │ ──→ │ 3. 分片处理 │
│ │ │ │ │ │
│ · 下载 M3U8 │ │ · Worker Pool│ │ · AES 解密 │
│ · 解析分片列表│ │ · 并发控制 │ │ · TS 拼接 │
│ · 提取密钥 │ │ · 指数退避 │ │ · FFmpeg 转封│
│ · 预估大小 │ │ · 进度上报 │ │ · 音视频合并 │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 6. 完成通知 │ ←── │ 5. 清理回收 │ ←── │ 4. 文件写入 │
│ │ │ │ │ │
│ · 桌面通知 │ │ · 释放内存 │ │ · FSA 流式 │
│ · 路径复制 │ │ · 清理临时文件│ │ · StreamSaver│
│ · 队列推进 │ │ · 重置状态 │ │ · Blob 兜底 │
└──────────────┘ └──────────────┘ └──────────────┘
引擎生命周期
下载引擎的一次完整运行经历以下状态转换:
[空闲]
│ 用户触发下载
▼
[初始化] ──── 加载 WASM、检查 API 可用性、选择写入策略
│
▼
[清单解析] ── 下载并解析 M3U8/MPD,提取分片列表和密钥
│
▼
[分片下载] ── Worker Pool 并发下载,实时上报进度
│
▼
[分片处理] ── 解密 → 拼接/转封装 → 写入磁盘
│
├── 成功 → [清理] → [完成] → [空闲]
│
├── 取消 → [清理] → [空闲]
│
└── 失败 → [清理] → [错误] → [空闲]
每个状态转换都会触发对应的生命周期钩子,UI 层通过监听这些钩子来更新界面状态。关于 UI 与引擎的交互细节,请参阅 项目架构 — 数据流。
分片下载模块
并发下载器
分片下载使用 Worker Pool 模式实现并发控制:
const downloadSegmentsConcurrently = async (
segments: SegmentInfo[],
onSegmentDownloaded: (buffer: ArrayBuffer, index: number) => void
) => {
const concurrency = Math.min(config.concurrency, 8)
let nextIndex = 0
const worker = async () => {
while (nextIndex < segments.length) {
const index = nextIndex++
const buffer = await downloadWithRetry(segments[index]!)
onSegmentDownloaded(buffer, index)
}
}
const workers = Array.from(
{ length: Math.min(concurrency, segments.length) },
() => worker()
)
await Promise.all(workers)
}
设计要点:
- 使用共享的
nextIndex计数器分配任务,避免预先分片导致负载不均 - Worker 数量不超过分片总数,避免创建空闲 Worker
- 每个 Worker 独立运行,单个分片失败不影响其他 Worker
Worker Pool 深入
Worker Pool 模式的核心优势在于动态负载均衡。与预先将分片数组切分为 N 等份不同,共享计数器确保:
预先分片(不推荐):
Worker 1: [分片 0-49] ← 如果这些分片较大,Worker 1 成为瓶颈
Worker 2: [分片 50-99] ← 可能提前完成,然后空闲等待
共享计数器(FlowPick 采用):
Worker 1: 分片 0 → 分片 3 → 分片 5 → ...
Worker 2: 分片 1 → 分片 4 → 分片 7 → ...
Worker 3: 分片 2 → 分片 6 → 分片 8 → ...
每个 Worker 完成当前分片后立即获取下一个未分配的分片,直到所有分片处理完毕。这种模式天然适应分片大小不均的场景(如 HLS 流的首尾分片通常较小,中间分片较大)。
异步生成器模式
对于超大文件(分片数量 > 500),引擎使用异步生成器(AsyncGenerator)模式避免一次性将所有分片数据加载到内存:
async function* segmentGenerator(
segments: SegmentInfo[],
signal?: AbortSignal
): AsyncGenerator<ArrayBuffer> {
for (const segment of segments) {
if (signal?.aborted) break
const buffer = await downloadWithRetry(segment)
yield buffer
}
}
与数组模式的区别:
| 维度 | 数组模式 | 生成器模式 |
|---|---|---|
| 内存峰值 | 所有分片同时在内存中 | 仅当前处理的分片在内存中 |
| 适用场景 | 分片数 < 500 | 分片数 > 500 |
| 进度追踪 | 已知总数,精确百分比 | 已知总数,精确百分比 |
| 取消响应 | 需等待当前批次完成 | 立即响应 |
引擎根据分片数量自动选择模式,无需用户干预。关于超大文件下载的实际场景,请参阅 直播回放保存。
重试机制
分片下载失败时采用指数退避重试:
const downloadWithRetry = async (
segment: SegmentInfo,
maxRetries = 3
): Promise<ArrayBuffer> => {
let lastError: unknown
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await downloadAndDecryptSegment(segment)
} catch (e) {
lastError = e
if (attempt < maxRetries - 1) {
// 指数退避:400ms, 800ms, 1600ms
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 400))
}
}
}
throw lastError
}
重试策略:
| 尝试次数 | 延迟 | 累计等待 |
|---|---|---|
| 第 1 次失败 | 400ms | 400ms |
| 第 2 次失败 | 800ms | 1200ms |
| 第 3 次失败 | 1600ms | 2800ms |
不触发重试的情况:
- HTTP 4xx 客户端错误(403、404 等)—— 重试无意义
- CORS 错误 —— 策略问题,重试不会改变结果
- 加密不支持错误 —— 无法通过重试解决
错误分类
class FetchError extends Error {
constructor(
message: string,
public status: number,
public url: string
) {
super(message)
this.name = 'FetchError'
}
}
错误按 HTTP 状态码分类处理:
| 状态码 | 错误类型 | 用户提示 |
|---|---|---|
| 403 | 认证/授权失败 | "访问被拒绝,请检查 URL 是否有效" |
| 404 | 资源不存在 | "分片未找到,流可能已过期" |
| 502/503/504 | 服务器错误 | "服务器暂时不可用,请稍后重试" |
| CORS | 跨域限制 | "跨域请求被阻止,建议使用扩展版本" |
| Network | 网络中断 | "网络连接失败,请检查网络" |
webRequest 权限可以绕过同源策略。更多排查方法请参阅 常见问题排查。关于 CORS 的技术原理和限制,请参阅 已知限制 — 浏览器限制。分片处理模块
AES-128 解密
对于加密的 HLS 流,FlowPick 使用 Web Crypto API 在浏览器中解密:
const decryptAES128 = async (
encryptedData: ArrayBuffer,
key: ArrayBuffer,
iv?: Uint8Array
): Promise<ArrayBuffer> => {
const keyBytes = await crypto.subtle.importKey(
'raw', key,
{ name: 'AES-CBC' },
false,
['decrypt']
)
const ivBuffer = iv ? new Uint8Array(iv) : new Uint8Array(16)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: ivBuffer },
keyBytes,
encryptedData
)
return decrypted
}
解密流程:
- 从 M3U8 的
#EXT-X-KEY标签提取密钥 URI 和 IV - 下载密钥文件(通常是 16 字节的二进制文件)
- 使用 AES-128-CBC 模式解密每个分片
- 如果未指定 IV,使用分片序号作为 IV(HLS 规范默认行为)
性能考虑:
- Web Crypto API 使用硬件加速,解密速度通常不是瓶颈
- 每个分片独立解密,可以与下载并行进行
- 密钥只需下载一次,缓存在内存中
TS 分片拼接
对于 TS 输出格式,分片直接按二进制拼接:
// 伪代码
const merged = new Uint8Array(totalSize)
let offset = 0
for (const segment of segments) {
merged.set(new Uint8Array(segment), offset)
offset += segment.byteLength
}
这种方式零 CPU 开销,速度仅受内存拷贝速度限制。
FFmpeg WASM 转封装
对于 MP4 输出格式,使用 FFmpeg WASM 进行容器转换。详见 格式转换 文档。
DASH 流的特殊处理
DASH 流与 HLS 流在分片处理上有显著差异:
HLS 流处理:
分片 0 → 分片 1 → 分片 2 → ... → 拼接 → 输出
DASH 流处理(音视频分离):
初始化段 → 视频分片 0 → 视频分片 1 → ... ─┐
├→ FFmpeg 合并 → 输出
初始化段 → 音频分片 0 → 音频分片 1 → ... ─┘
DASH 的 FMP4(Fragmented MP4)格式需要特殊处理:
- 初始化段(
ftyp+moovbox)必须放在文件开头 - 媒体段(
moof+mdatbox)按顺序追加 - 音视频分离时,需要分别下载视频和音频轨道,最后通过 FFmpeg 合并
文件写入模块
写入模块按优先级选择策略,确保在尽可能多的浏览器中工作。

策略一:File System Access API(最优)
async function createFSAStream(
filename: string,
dirHandle?: FileSystemDirectoryHandle | null
): Promise<WritableStream<Uint8Array>> {
let handle: FileSystemFileHandle
if (dirHandle) {
// 已有目录权限,直接创建文件
handle = await dirHandle.getFileHandle(filename, { create: true })
} else {
// 弹窗让用户选择保存位置
handle = await window.showSaveFilePicker!({
suggestedName: filename,
types: [{
description: 'Video',
accept: { 'video/mp4': ['.mp4'] }
}]
})
}
const writable = await handle.createWritable()
return new WritableStream<Uint8Array>({
async write(chunk) { await writable.write(chunk) },
async close() { await writable.close() },
async abort(reason) { await writable.abort(reason) }
}, {
highWaterMark: 16 * 1024 * 1024 // 16MB 缓冲区
})
}
优势:
- 流式写入,内存占用恒定(仅 16MB 缓冲区)
- 支持任意大小的文件
- 目录持久化后无需反复弹窗
限制:
- 仅 Chrome 86+ 和 Edge 86+ 支持
- 需要用户手势触发(
showDirectoryPicker必须在用户点击事件中调用)
策略二:StreamSaver.js(备选)
async function createStreamSaverStream(
filename: string,
fileSize?: number
): Promise<WritableStream<Uint8Array>> {
const ss = await getStreamSaver()
const fileStream = ss.createWriteStream(filename, { size: fileSize })
return fileStream
}
优势:
- 流式写入,内存占用低
- 兼容性优于 FSA API
限制:
- 需要 Service Worker 支持
- 需要
mitm.html和streamsaver-sw.js正确部署 - 部分企业网络环境可能阻止 Service Worker
策略三:Blob 兜底
// 伪代码
const blob = new Blob([mergedData], { type: 'video/mp4' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
限制:
- 整个文件加载到内存中
- 硬限制 1.5GB(
MEMORY_CONFIG.maxBlobSize) - 超过 800MB 时控制台警告(
MEMORY_CONFIG.warnBlobSize)
策略对比总结
| 维度 | FSA API | StreamSaver.js | Blob |
|---|---|---|---|
| 内存占用 | 16MB(恒定) | 低(流式) | 等于文件大小 |
| 文件大小上限 | 无限制 | 无限制 | 1.5GB |
| 浏览器要求 | Chrome/Edge 86+ | Service Worker 支持 | 所有浏览器 |
| 目录持久化 | 支持 | 不支持 | 不支持 |
| 下载体验 | 最佳 | 良好 | 一般 |
内存安全管理

大小预估
在开始下载前,引擎会采样预估总文件大小:
async function estimateSegmentsSize(
segments: ArrayBuffer[] | AsyncGenerator<ArrayBuffer>,
totalCount: number
): Promise<{ estimatedSize: number; isEstimate: boolean }> {
// 采样数量:min(5, max(1, totalCount * 0.1))
const sampleCount = Math.min(5, Math.max(1, Math.floor(totalCount * 0.1)))
// 下载前几个分片并计算平均大小
let sampledTotal = 0
for (let i = 0; i < sampleCount; i++) {
sampledTotal += sampleSegment.byteLength
}
const avgSample = sampledTotal / sampleCount
return {
estimatedSize: Math.round(avgSample * totalCount),
isEstimate: true
}
}
采样策略说明:
- 采样数量取
min(5, max(1, 分片总数 × 10%)),确保小流至少采样 1 个,大流最多采样 5 个 - 采样结果标记
isEstimate: true,UI 层据此显示"约 XX MB"而非精确值 - 对于 HLS 流,如果 Master Playlist 中声明了
BANDWIDTH属性,引擎会优先使用该值作为预估参考
策略选择逻辑
预估大小 < 800MB → 任意策略均可
预估大小 800MB-1.5GB → 优先流式写入,Blob 模式显示警告
预估大小 > 1.5GB → 强制流式写入,Blob 模式拒绝
内存配置常量
const MEMORY_CONFIG = {
maxBlobSize: 1500 * 1024 * 1024, // 1.5GB 硬限制
warnBlobSize: 800 * 1024 * 1024, // 800MB 警告阈值
}
运行时内存监控
除了下载前的预估,引擎在运行期间也会持续监控内存压力:
const checkMemoryPressure = (): 'normal' | 'warning' | 'critical' => {
// 检查当前已分配的分片缓冲区总大小
const allocatedMB = allocatedBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) / 1048576
if (allocatedMB > 1200) return 'critical'
if (allocatedMB > 600) return 'warning'
return 'normal'
}
当内存压力达到 critical 级别时,引擎会:
- 暂停新的分片下载请求
- 优先将已下载的分片写入磁盘
- 释放已完成写入的分片缓冲区
进度追踪
速度计算
使用滑动窗口计算实时下载速度:
const updateSpeedAndTime = () => {
const elapsed = (Date.now() - downloadStartTime) / 1000
const bytesPerSecond = downloadedBytes / elapsed
// 格式化速度显示
if (bytesPerSecond > 1024 * 1024) {
speed = `${(bytesPerSecond / 1048576).toFixed(1)} MB/s`
} else if (bytesPerSecond > 1024) {
speed = `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
} else {
speed = `${bytesPerSecond.toFixed(0)} B/s`
}
}
剩余时间估算
剩余时间 = (剩余分片数 × 平均分片大小) / 当前速度
使用节流函数(500ms 间隔)更新显示,避免频繁 DOM 更新。
进度阶段
| 阶段 | 进度范围 | 说明 |
|---|---|---|
| 解析 | 0% ~ 5% | 下载和解析清单文件 |
| 下载 | 5% ~ 85% | 并发下载分片 |
| 合并 | 85% ~ 100% | 拼接/转封装分片 |
进度数据结构
export interface StreamMergeProgress {
phase: 'downloading' | 'merging'
percent: number
downloadedBytes: number
totalBytes: number
speed: string
eta: string
}
UI 层通过 onProgress 回调接收进度更新,并根据 phase 字段切换界面状态(下载中 / 合并中)。关于进度在 UI 层的展示方式,请参阅 项目架构 — useStreamMerge.ts。
目录持久化
权限模型
// 选择目录并请求持久化权限
async function pickSaveDirectory(): Promise<string | null> {
const handle = await window.showDirectoryPicker!({
mode: 'readwrite',
id: 'flowpick-save-dir',
startIn: 'downloads'
})
const permissionState = await handle.requestPermission({
mode: 'readwrite'
})
if (permissionState === 'granted') {
savedDirHandle = handle
localStorage.setItem('flowpick_save_dir_name', handle.name)
return handle.name
}
}
权限恢复
页面加载时静默检查权限状态:
async function checkSavedDirPermission(): Promise<boolean> {
if (!savedDirHandle) return false
try {
const status = await navigator.permissions.query({
name: 'file-system',
handle: savedDirHandle
})
return status.state === 'granted'
} catch {
// query 失败时用实际操作测试
const testFile = await savedDirHandle
.getFileHandle(`__fp_test_${Date.now()}__`, { create: true })
const w = await testFile.createWritable()
await w.close()
return true
}
}
权限生命周期
首次使用:
用户点击"选择目录" → showDirectoryPicker() 弹窗 → 用户选择 → 权限授予 → 缓存句柄
再次打开页面:
读取缓存的目录名 → permissions.query() 静默检查 → 权限有效 → 直接使用
权限失效时:
permissions.query() 返回 'denied' → 清除缓存 → 提示用户重新选择目录
localStorage 缓存目录名,通过 navigator.permissions.query() 静默检查实际权限。关于浏览器兼容性,请参阅 浏览器兼容性 — File System Access API。取消与清理
下载支持通过 AbortSignal 取消:
export interface StreamMergeOptions {
segments: ArrayBuffer[] | AsyncGenerator<ArrayBuffer>
totalSegments: number
filename: string
outputFormat?: 'mp4' | 'ts'
onProgress?: (progress: StreamMergeProgress) => void
signal?: AbortSignal // 取消信号
}
取消时的清理步骤
取消操作触发后,引擎按以下顺序执行清理:
用户点击取消
│
▼
1. signal.abort() 触发
│
▼
2. 中断所有进行中的 fetch 请求
└── AbortController 关联的所有 fetch 立即抛出 AbortError
│
▼
3. 停止 Worker Pool
└── 所有 Worker 检测到 signal.aborted,退出循环
│
▼
4. 清理 FFmpeg 虚拟文件系统
└── 删除 filelist.txt、segment_*.ts、output.* 等临时文件
│
▼
5. 释放已分配的内存缓冲区
└── 将所有 ArrayBuffer 引用置空,等待 GC 回收
│
▼
6. 丢弃不完整的文件
└── FSA 模式:调用 writable.abort()
└── StreamSaver 模式:调用 writable.abort()
└── Blob 模式:不触发下载,直接释放 Blob
│
▼
7. 重置引擎状态
└── 清除进度数据、速度统计、错误信息
取消的传播机制
AbortSignal 在整个下载链路中逐层传递:
UI 层 AbortController
│
├──→ 分片下载 Worker Pool(中断 fetch)
├──→ 分片处理管线(跳过剩余分片)
├──→ FFmpeg WASM(终止转封装)
└──→ 文件写入流(abort 写入)
每一层都独立检查 signal.aborted,确保取消操作能快速响应。即使在 FFmpeg 转封装过程中取消,引擎也会等待当前 FFmpeg 操作完成一个原子步骤后再终止,避免虚拟文件系统损坏。
性能基准
以下数据基于 Chrome 126 + 100Mbps 网络环境测试,仅供参考:
| 场景 | 文件大小 | 并发数 | 下载耗时 | 合并耗时 | 总耗时 |
|---|---|---|---|---|---|
| 短视频(TS 输出) | ~50MB(30 分片) | 4 | ~8s | <1s | ~9s |
| 短视频(MP4 输出) | ~50MB(30 分片) | 4 | ~8s | ~3s | ~11s |
| 长视频(TS 输出) | ~500MB(200 分片) | 6 | ~45s | ~2s | ~47s |
| 长视频(MP4 输出) | ~500MB(200 分片) | 6 | ~45s | ~15s | ~60s |
| 超大文件(TS 输出) | ~2GB(800 分片) | 8 | ~3min | ~8s | ~3min |
| DASH 音视频分离 | ~300MB 视频 + ~30MB 音频 | 4 | ~30s | ~12s | ~42s |
影响因素:
| 因素 | 影响程度 | 说明 |
|---|---|---|
| CDN 速度 | 高 | 分片下载速度的上限 |
| 并发线程数 | 中 | 4-6 线程通常是最优区间 |
| 输出格式 | 中 | TS 输出跳过 FFmpeg,合并更快 |
| SharedArrayBuffer | 中 | 多线程 FFmpeg 比单线程快 40-60% |
| 加密流 | 低 | Web Crypto API 硬件加速,解密开销极小 |
并发数与性能的关系
并发数 1:████████████████████████████ 慢,带宽利用率低
并发数 2:██████████████████ 较快,基本可用
并发数 4:██████████ 快,推荐默认值
并发数 6:████████ 很快,接近最优
并发数 8:████████ 与 6 基本持平,边际收益递减
并发数 12+:████████ 可能触发 CDN 限速,反而变慢
错误边界与异常传播
下载引擎采用分层错误处理架构,确保每一层的异常都能被恰当捕获并转化为用户友好的提示:
┌─────────────────────────────────────────────┐
│ UI 层 │
│ · 展示用户提示(Toast/弹窗) │
│ · 更新下载状态为"失败" │
│ · 提供重试/反馈入口 │
└──────────────────┬──────────────────────────┘
│ 捕获所有异常
┌──────────────────▼──────────────────────────┐
│ 引擎门面层 │
│ · 统一异常格式(StreamMergeError) │
│ · 附加上下文信息(URL、分片索引、阶段) │
│ · 决定是否可重试 │
└──────────────────┬──────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│下载模块│ │处理模块 │ │写入模块 │
│ │ │ │ │ │
│FetchErr│ │CryptoErr │ │WriteErr │
└────────┘ └──────────┘ └──────────┘
错误类型映射:
| 底层错误 | 引擎错误类型 | 用户提示 | 可重试 |
|---|---|---|---|
FetchError(403) | AuthError | "访问被拒绝" | 否 |
FetchError(404) | NotFoundError | "资源已过期" | 否 |
FetchError(5xx) | ServerError | "服务器错误" | 是 |
TypeError: Failed to fetch | NetworkError | "网络连接失败" | 是 |
DOMException: AbortError | CancelledError | 无提示(静默) | — |
CryptoError | DecryptError | "解密失败" | 否 |
FFmpegError | MergeError | "合并失败" | 否 |
QuotaExceededError | StorageError | "存储空间不足" | 否 |
相关文档
- 视频嗅探 — HLS/DASH 清单解析与加密检测
- 格式转换 — FFmpeg WASM 引擎与 TSToMP4Muxer
- 批量下载 — 队列调度与并发控制
- 在线工具 — 写入策略在在线工具中的实际应用
- 浏览器兼容性 — 各浏览器 API 支持与降级策略
- 隐私与安全 — 本地处理,零数据上传
- 配置参考 — 并发数、输出格式等配置项
- 项目架构 — 引擎在整体系统中的位置与模块交互
- 贡献指南 — 为下载引擎贡献代码
- 直播回放保存 — 超大文件下载的实际场景
- 在线课程下载 — 课程视频下载场景
- 视频平台下载 — 主流平台下载实践
- 常见问题排查 — 下载失败诊断
- 已知问题 — 下载引擎的已知限制
- 常见问题解答 — 下载相关高频问题