从 fMP4 到标准 MP4:浏览器端无重编码视频合并方案设计

67 阅读12分钟

一、背景与问题

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 重新封装视频和音频流

image.png

优点

  • 兼容性好
  • 可以解决 Duplicated SDTP atom 问题

缺点

  • 需要加载约 32 MB 的依赖
  • 性能比 WebCodecs 差
  • 有 2GB 运行内存限制

方案三:WebCodecs + muxjs(最终选择)

实现方式

  1. 使用 muxjs 将 TS 转换为 fMP4
  2. 手动操作 MP4 Box 结构合并 fMP4 片段
  3. 修正 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 中。

image.png

三、解决方案

image.png

3.1 整体架构

采用优雅降级策略

用户发起剪辑/下载
    ↓
检测浏览器是否支持(Chrome 102+)
    ↓
支持 → 客户端方案(M3U8 + WebCodecs)
    ↓
不支持 → 服务端方案(ffmpeg 剪辑)
    ↓
客户端方案失败 → 降级到服务端方案

3.2 方案设计要点

  1. 服务端改造

    • 将原始录屏切片转换为 TS 片段存储
    • 根据剪辑区间生成 M3U8 索引文件
    • 保留 ffmpeg 剪辑能力作为兜底
  2. 客户端实现

    • 下载 M3U8 文件并解析 TS 片段列表
    • 使用队列控制并发下载 TS 片段
    • 使用 muxjs 将 TS 转换为 fMP4
    • 手动合并 fMP4 为标准 MP4
    • 修正 MP4 元数据(duration)
  3. 兼容性处理

    • 浏览器版本检测(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 字段可能不准确,导致:

  • 播放器显示错误的时长
  • 进度条拖动异常
  • 部分播放器无法正常播放

修正方法

  1. 从 M3U8 文件计算实际总时长
  2. 读取 mvhd box 中的 timescale(时间刻度)
  3. 计算 duration = 实际时长 × timescale
  4. 将新的 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 容器格式,在浏览器环境下实现了高性能的视频合并能力。核心创新点在于:

  1. 手动操作 MP4 Box:不依赖重编码,直接操作容器格式
  2. 精确的元数据修正:确保生成的 MP4 文件完全符合规范
  3. 优雅的降级策略:兼顾新技术和兼容性

该方案已在生产环境验证,能够覆盖 90%+ 的用户,显著降低了成本并提升了用户体验。