一、背景与问题
1.1 业务背景
在当前业务的场景中,我们向用户提供直播间视频,用户会根据自己的需求指定起止时间下载对应的片段,当前的方案是存储直播间视频数据,针对用户每一次的片段下载都需要在服务端发起 ffmepg剪辑并将剪辑产物上传至 oss 分发给用户下载。这个方案存在以下问题:
- 高存储成本:服务端需要针对每一次剪辑任务存储完整的 MP4 文件到 OSS,冗余存储
- 高算力成本:所有视频合成工作都在服务端通过 ffmpeg 完成
- 用户体验差:用户发起下载任务后需要等待服务端剪辑完成
1.2 核心问题
如何在客户端(浏览器)环境下,将多个 TS 视频片段合并成一个可正常播放的 MP4 文件?
浏览器环境的限制:
- 算力有限,无法进行 CPU 密集型的重新编码
- 需要在不改变音视频编码的情况下完成合并
- 需要考虑兼容性问题
二、调研过程
2.1 用户浏览器分布调研
通过分析 2024 年 6 月蝉剪用户的浏览器使用数据:
- Chrome(Chromium 内核):88.7% 使用率
- Chrome 102 以下版本:5.5%(无法使用 WebCodecs)
- QQ 浏览器:4.5%(Chromium 内核过低,无法使用)
- Firefox:0.97%(全版本不支持 WebCodecs)
- Safari:3.8%(需验证)
- 其他浏览器:约 2%
结论:明确可兼容 83.2%,明确不兼容 5.47%,未验证 11.33%
2.2 技术方案调研
方案一:new Blob 直接合并
实现方式:
const fileBlob = new Blob(fileDataList, { type: 'video/MP2T' })
问题:
- 合成的 MP4 文件会有 "Duplicated SDTP atom" 警告
- 原因:MP4 格式有容器层,需要先解开容器再拼接视频流
- 结果:兼容性不佳,部分播放器无法正常播放
方案二:ffmpeg-wasm
实现方式:使用 ffmpeg -c copy 重新封装视频和音频流
优点:
- 兼容性好
- 可以解决 Duplicated SDTP atom 问题
缺点:
- 需要加载约 32 MB 的依赖
- 性能比 WebCodecs 差
- 有 2GB 运行内存限制
方案三:WebCodecs + muxjs(最终选择)
实现方式:
- 使用 muxjs 将 TS 转换为 fMP4
- 手动操作 MP4 Box 结构合并 fMP4 片段
- 修正 MP4 的 duration 元数据
优点:
- 性能最佳(浏览器原生能力)
- 无需加载额外大型依赖
- 无内存限制
缺点:
- 兼容性要求:Chrome 102+
- 需要手动处理 MP4 Box 结构
2.3 容器格式对比
fMP4(Fragmented MP4)结构:
ftyp box (文件类型)
↓
moov box (元数据)
↓
moof box (片段元数据1) → mdat box (片段数据1)
↓
moof box (片段元数据2) → mdat box (片段数据2)
↓
...
标准 MP4 结构:
ftyp box (文件类型)
↓
moov box (元数据 - 包含所有片段信息)
↓
mdat box (所有媒体数据)
核心差异:fMP4 的元数据分散在各个片段中,标准 MP4 的元数据集中在 moov box 中。
三、解决方案
3.1 整体架构
采用优雅降级策略:
用户发起剪辑/下载
↓
检测浏览器是否支持(Chrome 102+)
↓
支持 → 客户端方案(M3U8 + WebCodecs)
↓
不支持 → 服务端方案(ffmpeg 剪辑)
↓
客户端方案失败 → 降级到服务端方案
3.2 方案设计要点
-
服务端改造:
- 将原始录屏切片转换为 TS 片段存储
- 根据剪辑区间生成 M3U8 索引文件
- 保留 ffmpeg 剪辑能力作为兜底
-
客户端实现:
- 下载 M3U8 文件并解析 TS 片段列表
- 使用队列控制并发下载 TS 片段
- 使用 muxjs 将 TS 转换为 fMP4
- 手动合并 fMP4 为标准 MP4
- 修正 MP4 元数据(duration)
-
兼容性处理:
- 浏览器版本检测(Chrome 102+)
- 视频时长限制(默认 2 分钟,可配置)
- 异常降级到服务端方案
3.3 核心流程
graph TD
A[开始] --> B[下载 M3U8 文件内容]
B --> C[计算视频总时长]
C --> D[记录视频时长日志并上报]
D --> E[解析 TS 片段 URL 列表]
E --> F[检查 muxjs 库是否加载]
F --> G{muxjs 已加载?}
G -->|否| H[抛出错误]
G -->|是| I[下载并转换 TS 片段为 fMP4 片段]
I --> J[合并 fMP4 片段为完整 MP4 文件]
J --> K[修正 MP4 的 duration 元数据]
K --> L[创建 Uint8Array 流]
L --> N[创建 Blob 对象]
N --> O[生成 Blob URL]
O --> P[确保文件名以 .mp4 结尾]
P --> Q{是否在浏览器环境?}
Q -->|是| R[创建下载按钮并触发下载]
Q -->|否| S[跳过下载按钮创建]
R --> T[释放 Blob URL]
S --> T
T --> U[结束]
四、核心代码实现
4.1 主函数:downloadVideoClip
/**
* 下载视频片段
* @param m3u8Url - M3U8 文件的 URL
* @param fileName - 保存的文件名
* @param onProgress - 进度回调函数
* @param onError - 错误回调函数
*/
export async function downloadVideoClip(
m3u8Url: string,
fileName: string,
onProgress?: (progress: number) => void,
onError?: (error: Error) => void
): Promise<void> {
try {
// 1. 下载 M3U8 文件内容
const m3u8Response = await fetch(m3u8Url)
const m3u8Content = await m3u8Response.text()
// 2. 计算视频总时长(用于日志和监控)
const totalDuration = calculateTotalDuration(m3u8Content)
// 3. 解析 TS 片段 URL 列表
const segmentUrls = parseM3u8(m3u8Content, m3u8Url)
// 4. 检查 muxjs 是否已加载
if (!muxjs || !muxjs.mp4 || !muxjs.mp4.Transmuxer) {
throw new Error('muxjs library is not loaded')
}
// 5. 下载并转换 TS 片段为 fMP4
const fmp4Segments = await downloadAndConvertSegments(
segmentUrls,
onProgress
)
// 6. 合并 fMP4 片段为完整 MP4
const mergedMp4 = mergeFMP4Segments(fmp4Segments)
// 7. 修正 MP4 的 duration 元数据
const finalMp4 = fixMp4Duration(mergedMp4, totalDuration)
// 8. 创建 Blob 并触发下载
const blob = new Blob([finalMp4], { type: 'video/mp4' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName.endsWith('.mp4') ? fileName : `${fileName}.mp4`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
onError?.(error as Error)
throw error
}
}
4.2 TS 转 fMP4:downloadAndConvertSegments
/**
* 下载并转换 TS 片段为 fMP4
*/
async function downloadAndConvertSegments(
segmentUrls: string[],
onProgress?: (progress: number) => void
): Promise<Uint8Array[]> {
const fmp4Segments: Uint8Array[] = []
const taskQueue = new TaskQueue(6) // 控制并发数为 6
for (let i = 0; i < segmentUrls.length; i++) {
await taskQueue.add(async () => {
// 下载 TS 片段
const response = await fetch(segmentUrls[i])
const tsData = await response.arrayBuffer()
// 使用 muxjs 将 TS 转换为 fMP4
const fmp4Data = await convertTsToFmp4(new Uint8Array(tsData))
fmp4Segments[i] = fmp4Data
// 更新进度
const progress = ((i + 1) / segmentUrls.length) * 100
onProgress?.(progress)
})
}
return fmp4Segments
}
/**
* 使用 muxjs 将 TS 转换为 fMP4
*/
function convertTsToFmp4(tsData: Uint8Array): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const transmuxer = new muxjs.mp4.Transmuxer()
const segments: Uint8Array[] = []
transmuxer.on('data', (segment: any) => {
// 合并音频和视频数据
const data = new Uint8Array(
segment.initSegment.byteLength + segment.data.byteLength
)
data.set(segment.initSegment, 0)
data.set(segment.data, segment.initSegment.byteLength)
segments.push(data)
})
transmuxer.on('done', () => {
// 合并所有片段
const result = concatTypedArrays(...segments)
resolve(result)
})
transmuxer.push(tsData)
transmuxer.flush()
})
}
4.3 合并 fMP4 片段:mergeFMP4Segments
这是整个方案的核心,需要理解 MP4 Box 结构。
/**
* 合并多个 fMP4 片段为一个标准 MP4 文件
*
* 核心思路:
* 1. 提取第一个片段的 ftyp 和 moov box(文件头和元数据)
* 2. 提取所有片段的 moof 和 mdat box(片段元数据和媒体数据)
* 3. 按照标准 MP4 格式重新组装
*/
function mergeFMP4Segments(segments: Uint8Array[]): Uint8Array {
if (segments.length === 0) {
throw new Error('No segments to merge')
}
// 存储所有需要合并的 box
const boxes: Uint8Array[] = []
// 1. 从第一个片段提取 ftyp 和 moov box
const firstSegment = segments[0]
let offset = 0
// 提取 ftyp box
const ftypBox = extractBox(firstSegment, 'ftyp', offset)
if (ftypBox) {
boxes.push(ftypBox.data)
offset = ftypBox.end
}
// 提取 moov box
const moovBox = extractBox(firstSegment, 'moov', offset)
if (moovBox) {
boxes.push(moovBox.data)
}
// 2. 从所有片段提取 moof 和 mdat box
for (const segment of segments) {
let segmentOffset = 0
// 跳过 ftyp 和 moov(只在第一个片段中需要)
const segmentFtyp = extractBox(segment, 'ftyp', segmentOffset)
if (segmentFtyp) {
segmentOffset = segmentFtyp.end
}
const segmentMoov = extractBox(segment, 'moov', segmentOffset)
if (segmentMoov) {
segmentOffset = segmentMoov.end
}
// 提取所有 moof 和 mdat box
while (segmentOffset < segment.length) {
const moofBox = extractBox(segment, 'moof', segmentOffset)
if (moofBox) {
boxes.push(moofBox.data)
segmentOffset = moofBox.end
// moof 后面通常紧跟 mdat
const mdatBox = extractBox(segment, 'mdat', segmentOffset)
if (mdatBox) {
boxes.push(mdatBox.data)
segmentOffset = mdatBox.end
}
} else {
break
}
}
}
// 3. 合并所有 box
return concatTypedArrays(...boxes)
}
4.4 MP4 Box 操作:extractBox
/**
* 从 MP4 数据中提取指定类型的 box
*
* MP4 Box 结构:
* - 前 4 字节:box 大小(包含这 4 字节)
* - 接下来 4 字节:box 类型(如 'ftyp', 'moov', 'mdat')
* - 剩余字节:box 数据
*/
function extractBox(
data: Uint8Array,
boxType: string,
startOffset: number = 0
): { data: Uint8Array; end: number } | null {
if (startOffset >= data.length) {
return null
}
// 读取 box 大小(大端序,4 字节)
const size = (data[startOffset] << 24)
| (data[startOffset + 1] << 16)
| (data[startOffset + 2] << 8)
| data[startOffset + 3]
// 读取 box 类型(4 字节 ASCII)
const type = String.fromCharCode(
data[startOffset + 4],
data[startOffset + 5],
data[startOffset + 6],
data[startOffset + 7]
)
// 检查是否是目标 box
if (type === boxType) {
return {
data: data.slice(startOffset, startOffset + size),
end: startOffset + size
}
}
return null
}
4.5 修正 MP4 Duration:fixMp4Duration
这是确保视频能正确播放的关键步骤。
/**
* 修正 MP4 文件的 duration 元数据
*
* 问题:合并后的 MP4 文件的 duration 可能不准确
* 解决:手动修改 moov.mvhd box 中的 duration 字段
*
* mvhd box 结构:
* - version (1 byte): 0 或 1
* - flags (3 bytes)
* - creation_time (4/8 bytes): 创建时间
* - modification_time (4/8 bytes): 修改时间
* - timescale (4 bytes): 时间刻度(每秒的时间单位数)
* - duration (4/8 bytes): 持续时间(以 timescale 为单位)
*/
function fixMp4Duration(
mp4Data: Uint8Array,
durationInSeconds: number
): Uint8Array {
// 1. 查找 moov box
const moovStart = findBox(mp4Data, 'moov', 0)
if (moovStart === -1) {
return mp4Data
}
// 2. 在 moov box 中查找 mvhd box
const moovSize = readBoxSize(mp4Data, moovStart)
const mvhdStart = findBox(mp4Data, 'mvhd', moovStart + 8)
if (mvhdStart === -1) {
return mp4Data
}
// 3. 读取 mvhd 版本
const mvhdVersion = mp4Data[mvhdStart + 8] // 跳过 size(4) + type(4)
// 4. 读取 timescale
// version 0: size(4) + type(4) + version(1) + flags(3) + creation_time(4) + modification_time(4) + timescale(4)
// version 1: size(4) + type(4) + version(1) + flags(3) + creation_time(8) + modification_time(8) + timescale(4)
const timescaleOffset = mvhdVersion === 1
? mvhdStart + 8 + 4 + 8 + 8 // version 1
: mvhdStart + 8 + 4 + 4 + 4 // version 0
const timescale = (mp4Data[timescaleOffset] << 24)
| (mp4Data[timescaleOffset + 1] << 16)
| (mp4Data[timescaleOffset + 2] << 8)
| mp4Data[timescaleOffset + 3]
// 5. 计算新的 duration 值(时间单位)
const durationInTimeScale = Math.round(durationInSeconds * timescale)
// 6. 确定 duration 字段的位置
const durationOffset = mvhdVersion === 1
? timescaleOffset + 4 + 8 // version 1: timescale(4) + 8字节占位
: timescaleOffset + 4 // version 0: timescale(4)后面
// 7. 创建新数组以修改 duration
const result = new Uint8Array(mp4Data)
if (mvhdVersion === 1) {
// version 1: duration 是 64 位(8 字节)
// 设置高 32 位为 0(通常持续时间不会超过 32 位表示范围)
result[durationOffset] = 0
result[durationOffset + 1] = 0
result[durationOffset + 2] = 0
result[durationOffset + 3] = 0
// 设置低 32 位
result[durationOffset + 4] = (durationInTimeScale >> 24) & 0xFF
result[durationOffset + 5] = (durationInTimeScale >> 16) & 0xFF
result[durationOffset + 6] = (durationInTimeScale >> 8) & 0xFF
result[durationOffset + 7] = durationInTimeScale & 0xFF
} else {
// version 0: duration 是 32 位(4 字节)
result[durationOffset] = (durationInTimeScale >> 24) & 0xFF
result[durationOffset + 1] = (durationInTimeScale >> 16) & 0xFF
result[durationOffset + 2] = (durationInTimeScale >> 8) & 0xFF
result[durationOffset + 3] = durationInTimeScale & 0xFF
}
return result
}
/**
* 查找指定类型的 box 在数据中的起始位置
*/
function findBox(
data: Uint8Array,
boxType: string,
startOffset: number = 0
): number {
let offset = startOffset
while (offset < data.length - 8) {
const size = readBoxSize(data, offset)
const type = readBoxType(data, offset)
if (type === boxType) {
return offset
}
offset += size
}
return -1
}
/**
* 读取 box 大小(大端序)
*/
function readBoxSize(data: Uint8Array, offset: number): number {
return (data[offset] << 24)
| (data[offset + 1] << 16)
| (data[offset + 2] << 8)
| data[offset + 3]
}
/**
* 读取 box 类型(4 字节 ASCII)
*/
function readBoxType(data: Uint8Array, offset: number): string {
return String.fromCharCode(
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7]
)
}
五、关键技术点解析
5.1 MP4 Box 结构详解
MP4 文件是由一系列 "box"(也称为 "atom")组成的容器格式。每个 box 的基本结构:
+-------------------+
| size (4 bytes) | box 总大小(包含这 4 字节)
+-------------------+
| type (4 bytes) | box 类型(ASCII 字符,如 'ftyp')
+-------------------+
| data (variable) | box 数据
+-------------------+
重要的 box 类型:
- ftyp:文件类型,标识 MP4 的兼容性
- moov:元数据容器,包含所有媒体信息
- mvhd:电影头,包含 duration、timescale 等
- trak:轨道信息(视频轨、音频轨)
- mdat:媒体数据,实际的音视频内容
- moof:片段元数据(fMP4 特有)
5.2 fMP4 vs 标准 MP4
fMP4(Fragmented MP4):
- 元数据分散:每个片段都有自己的 moof box
- 适合流式传输:可以边下载边播放
- HLS/DASH 常用格式
标准 MP4:
- 元数据集中:所有信息在 moov box 中
- 需要完整下载:播放前需要读取 moov box
- 更好的兼容性
5.3 Duration 修正的必要性
合并 fMP4 片段后,moov box 中的 duration 字段可能不准确,导致:
- 播放器显示错误的时长
- 进度条拖动异常
- 部分播放器无法正常播放
修正方法:
- 从 M3U8 文件计算实际总时长
- 读取 mvhd box 中的 timescale(时间刻度)
- 计算 duration = 实际时长 × timescale
- 将新的 duration 写入 mvhd box
timescale 说明:
- timescale 定义了每秒有多少个时间单位
- 例如:timescale = 1000,表示每秒 1000 个单位
- duration = 5000,表示 5000 / 1000 = 5 秒
5.4 并发控制
使用 TaskQueue 控制并发下载数量(默认 6):
const taskQueue = new TaskQueue(6)
for (const url of segmentUrls) {
await taskQueue.add(async () => {
// 下载和处理逻辑
})
}
原因:
- 浏览器对同一域名有并发限制(通常 6-8 个)
- 避免过多并发导致请求失败
- 保持稳定的下载速度
六、兼容性与降级策略
6.1 浏览器检测
export const canUseM3u8 = (() => {
let m3u8SupportCache: boolean | null = null
return () => {
if (m3u8SupportCache !== null) {
return m3u8SupportCache
}
const isChrome = navigator.userAgent.includes('Chrome')
&& !navigator.userAgent.includes('Edg')
&& !navigator.userAgent.includes('OPR')
if (!isChrome) {
m3u8SupportCache = false
return false
}
const version = parseInt(
navigator.userAgent.match(/Chrome\/(\d+)/)?.[1] ?? '0',
10
)
m3u8SupportCache = version >= 102
return m3u8SupportCache
}
})()
6.2 时长限制
const M3U8_DURATION_LIMIT = 2 * 60 // 默认 2 分钟
export function canUseM3u8Duration(duration: number) {
return duration <= M3U8_DURATION_LIMIT
}
原因:
- 当前业务 90% 的场景是 2 分钟内的片段剪辑
- 避免长视频导致浏览器内存溢出
- 平衡用户体验和技术限制
6.3 降级流程
try {
// 尝试客户端方案
await downloadVideoClip(m3u8Url, fileName, onProgress)
} catch (error) {
// 降级到服务端方案
await downloadFromServer(videoId, fileName)
}
七、方案价值
7.1 成本降低
- 存储成本:无需针对每次剪辑存储 MP4 文件
- 算力成本:视频合成转移到客户端,服务端仅需生成 M3U8 索引
7.2 用户体验提升
- 即时下载:无需等待服务端剪辑,点击即下载
- 进度可见:实时显示下载和处理进度
7.3 技术前瞻性
- 随着浏览器版本更新,兼容性会逐步提升
- 为未来的纯客户端方案奠定基础
八、参考资料
九、总结
本方案通过深入理解 MP4 容器格式,在浏览器环境下实现了高性能的视频合并能力。核心创新点在于:
- 手动操作 MP4 Box:不依赖重编码,直接操作容器格式
- 精确的元数据修正:确保生成的 MP4 文件完全符合规范
- 优雅的降级策略:兼顾新技术和兼容性
该方案已在生产环境验证,能够覆盖 90%+ 的用户,显著降低了成本并提升了用户体验。