Skip to content

MSE + fetch 流如何把 FLV 喂进 <video>

更新: 5/4/2026 字数: 0 字 时长: 0 分钟

一、一个尴尬的事实:<video> 不认 FLV

打开浏览器控制台,直接写:

html
<video src="https://cdn.example.com/live/room.flv" controls></video>

Chrome 会给你一个干脆的 MEDIA_ERR_SRC_NOT_SUPPORTED。原因不在编码,而在容器

  • 编码 vs 容器是两件事:H.264/AAC 是编码(codec),FLV/MP4/WebM 是容器(container)。浏览器的 <video> 在解码器上基本都支持 H.264/AAC,但在容器解析器上只认 MP4(ISO BMFF)、WebM、Ogg,外加 Safari 的 HLS。
  • FLV 是 Adobe 时代的遗产:为 Flash Player 设计的流式容器,结构简单(Tag 列表 + 起始三字节 FLV),但浏览器厂商从未打算内置它的 demuxer。Flash 死后,FLV 就成了"编码能解、容器不识"的尴尬格式。
  • 为什么不直接让服务端转 MP4? 标准 MP4 的 moov box 要求先知道整段时长与索引,不适合直播这种无限流。就算用 fragmented MP4(fMP4),也需要一套转封装逻辑——这正是前端播放器内核要做的事

结论很明确:要在浏览器播 FLV,必须在 JS 里把 FLV 拆开,重新封装成 fMP4,再通过 MSE 喂给 <video>

二、MSE 工作原理:给 <video> 开一条"后门"

Media Source Extensions 做的事情,本质上是<video> 的数据源从"URL"改成"JS 里的 Buffer"

2.1 基本对象模型

MediaSource ──┬── SourceBuffer (video: video/mp4; codecs="avc1.64001f")
              └── SourceBuffer (audio: audio/mp4; codecs="mp4a.40.2")
  • MediaSource:虚拟媒体源。通过 URL.createObjectURL(mediaSource) 产生一个 blob: URL,塞给 <video>.src,浏览器就把它当作媒体源。
  • SourceBuffer:每个轨道一个(也可以合一),JS 通过 appendBuffer(ArrayBuffer) 持续喂数据。
  • 时间轴管理:喂进去的必须是带 DTS/PTS 的 fMP4 片段,浏览器按时间戳解码、排队、渲染。

2.2 最小可用骨架

js
const video = document.querySelector('video')
const ms = new MediaSource()
video.src = URL.createObjectURL(ms)

ms.addEventListener('sourceopen', () => {
  // codecs 字符串必须精确匹配 SPS/PPS 推断出的 profile/level
  const sb = ms.addSourceBuffer('video/mp4; codecs="avc1.64001f,mp4a.40.2"')

  sb.mode = 'segments'          // 直播常用;点播有时用 'sequence'
  sb.addEventListener('updateend', onAppendDone)

  // 后续 sb.appendBuffer(fmp4Chunk) 持续注入
})

2.3 几个必须知道的"硬规则"

  • appendBuffer 是串行的sb.updating === true 时再 append 会抛 InvalidStateError。必须排队。
  • SourceBuffer 有水位:浏览器内部缓冲通常 100–150MB,超过会 QuotaExceededError。直播要定期 sb.remove(start, end) 清理旧数据。
  • 首块必须是 init segment:fMP4 的 ftyp + moov 头必须先到,之后才是 moof + mdat 的媒体片段。
  • codecs 字符串要和 SPS 一致:H.264 的 avc1.PPCCLL(profile/compatibility/level)写错了,某些设备会黑屏不报错。要从 SPS 里解出来动态生成。
  • iOS Safari 不支持<video> 上的 MSE 在 iPhone 上至今缺席(iPadOS 14+ 才放开),这是 FLV 在 iPhone 永远播不了的根因。

三、FLV → fMP4 的完整链路

整条管线可以拆成四段:拉流 → Demux → Remux → Append

fetch ReadableStream
        │  (Uint8Array chunks)

┌─────────────────────┐
│  FLV Demuxer        │  解析 Tag: Script/Video/Audio
│   - AVC sequence hdr│  抽出 SPS/PPS、AudioSpecificConfig
│   - NALU / AAC RAW  │  产出带时间戳的 ES 帧
└─────────────────────┘
        │  { type, dts, pts, data }

┌─────────────────────┐
│  fMP4 Remuxer       │  拼 ftyp/moov(init segment)
│   - NALU → AVCC     │  每 GOP 拼 moof + mdat(media segment)
│   - AAC → mp4a      │  维护 baseMediaDecodeTime
└─────────────────────┘
        │  ArrayBuffer

   MSE SourceBuffer.appendBuffer()


   <video> 播放

3.1 FLV Demux:把 Tag 流拆成 ES 帧

FLV 的结构非常直白:

FLV Header (9 bytes)           // "FLV" + version + flags
PreviousTagSize0 (4 bytes)
[ FLV Tag (11-byte header + data + PreviousTagSize) ]*

Tag 分三类:

Type内容关键载荷
0x12 ScriptonMetaData(宽高、帧率、时长)AMF0/AMF3 对象
0x09 VideoVideoTagHeader + AVCPacket首包是 AVCDecoderConfigurationRecord(SPS/PPS),之后是 NALU
0x08 AudioAudioTagHeader + AACPacket首包是 AudioSpecificConfig,之后是 AAC Raw

实战要点:

  • SPS/PPS 必须截取:直接拿 AVCDecoderConfigurationRecord 里的 SPS/PPS 拼 fMP4 的 avcC box;不要尝试自己从 NALU 流里凑。
  • NALU 长度前缀要改:FLV 里是 AVCC 格式(4 字节长度前缀),fMP4 mdat 也要 AVCC——看似一样,但要确保长度字段是 big-endian 4 字节,别漏转。
  • 时间戳的坑:FLV 的 timestamp 是毫秒、24 位 + 8 位扩展,拼成 32 位有符号。直播跑过 24 天会翻转,需要做环绕处理。Video Tag 还有 CompositionTimepts = dts + cts
  • AAC ADTS vs RAW:FLV 里的 AAC 通常是 RAW(没有 ADTS 头),可以直接进 fMP4;如果你的推流端塞了 ADTS,要先剥掉 7/9 字节头。

3.2 fMP4 Remux:拼 init segment 与 media segment

fMP4(ISO BMFF fragmented)的结构:

Init Segment:  ftyp + moov (mvhd + trak*n + mvex)
Media Segment: (moof + mdat)*  // 每个片段自包含时间戳
  • init segment 只发一次sourceopen 后第一个 appendBuffer 必须是它;后续换分辨率/换码率要先 changeType() 再发新的 init。
  • 每个 moof 的 baseMediaDecodeTime 要连续:直播常见 bug 是"卡一下跳一下",多半是 tfdt 的时间戳算错了。用 DTS 不要用 PTS。
  • GOP 对齐:直播建议每个 media segment 对应一个 GOP(以 IDR 起始)。MSE 在 seek/断流后只认 IDR,GOP 跨片会导致首帧花屏。
  • 音视频独立 SourceBuffer:推荐音视频各一条 SourceBuffer,避免一路卡住拖累另一路;也可以合流到单个 SourceBuffer,减少抖动但调试更难。

3.3 喂 MSE:一个简化版 append 队列

js
class AppendQueue {
  constructor(sb) {
    this.sb = sb
    this.queue = []
    sb.addEventListener('updateend', () => this.flush())
  }
  push(buf) {
    this.queue.push(buf)
    this.flush()
  }
  flush() {
    if (this.sb.updating || !this.queue.length) return
    try {
      this.sb.appendBuffer(this.queue.shift())
    } catch (e) {
      if (e.name === 'QuotaExceededError') {
        // 直播裁剪:只保留最近 30s
        const end = this.sb.buffered.end(this.sb.buffered.length - 1)
        this.sb.remove(0, end - 30)
      }
    }
  }
}

四、为什么用 fetch ReadableStream,而不是 XHR

HTTP-FLV 的本质是长连接 HTTP chunked transfer:服务端持续不断地推字节,客户端一边收一边播。对这种场景:

  • XHR 做不到流式消费XMLHttpRequestresponseType = 'arraybuffer' 要等请求结束才能拿到数据;responseType = 'text' 可以拿到增量,但是字符串,二进制会被编码破坏。用 XHR 玩 FLV 只能退化成"分段拉",失去长连接优势。
  • fetch + ReadableStream 才是正解res.body.getReader() 返回 Uint8Array 分片,真·流式,天然适配 demuxer。
js
const res = await fetch(flvUrl, { signal: abortController.signal })
const reader = res.body.getReader()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  demuxer.push(value)           // 交给 FLV demuxer 继续解析
}

4.1 背压(Backpressure)怎么做

ReadableStream 本身自带背压:只要你不 read(),TCP 窗口就不会消耗,服务端自然被推回。但如果你无脑 read → demux → append,MSE 的 SourceBuffer 很快会撑爆 Quota。实战策略:

  1. 用"SourceBuffer 水位"反向控速:append 前检查 sb.buffered.end - video.currentTime,超过阈值(比如 10 秒)就停止 reader.read(),让 TCP 天然回压。
  2. 活跃驱逐:定期 sb.remove(0, currentTime - 5),直播不需要保留历史,缓冲区维持在 5–15 秒窗口最舒服。
  3. 追帧:观众长时间挂后台,回来时 buffered.end - currentTime 可能已经到 20 秒,要主动 video.currentTime = buffered.end - 2 把水位压回目标延迟。

4.2 断流重连

直播链路会被各种东西中断:Wi-Fi 切 4G、运营商掐 HTTP 长连接、CDN 节点漂移。重连的几个原则:

  • 区分错误类型TypeError: network error(fetch abort)、reader.read() resolve 但 done=true 却时间不够(服务端主动关),都要走重连;MediaError 要区分 MEDIA_ERR_DECODE(多半是时间戳跳变)和 SRC_NOT_SUPPORTED(码流变更,要重建 SourceBuffer)。
  • 指数退避 + 抖动1s, 2s, 4s, 8s, max 10s,避免服务端重启瞬间雪崩。
  • 时间戳要续上:重连后新流的 timestamp 通常从 0 开始。demuxer 要记住上次最后一帧的 DTS,给新流加 offset;否则 <video> 会判定"时间倒流"而卡死。
  • 不要 reset 整个 MediaSource:能用 sb.abort() + sb.remove() 就别重建;endOfStream() 之后 MediaSource 是只读的,重连意味着从头创建 blob URL,代价更大。
  • Worker 化:demux/remux 放进 Web Worker,避免主线程卡顿导致的喂帧延迟;flv.js / mpegts.js 的新版都在往这个方向走。

五、WebCodecs 会怎么改写这一切

Chrome 94+ 落地的 WebCodecs API 提供了 VideoDecoder / AudioDecoder / VideoEncoder,直接暴露浏览器底层硬件解码器。对 FLV 播放链路意味着:

5.1 新链路:绕开 MSE

fetch ReadableStream


FLV Demuxer  ──►  EncodedVideoChunk { data, timestamp, type: 'key'|'delta' }
    │                                  │
    │                                  ▼
    │                          VideoDecoder.decode()
    │                                  │
    │                                  ▼
    │                          VideoFrame  ──►  canvas / OffscreenCanvas
    │                                           或 <video>.requestVideoFrameCallback

AAC Demuxer ──►  EncodedAudioChunk  ──►  AudioDecoder  ──►  AudioData ──► AudioWorklet
  • 不再需要 fMP4 remux:直接把 NALU 包成 EncodedVideoChunk 丢给 VideoDecoder,省掉最烧脑的那一步。
  • 不再受 SourceBuffer 水位限制:你自己控制 VideoFrame 队列,想压到 200ms 延迟就压 200ms。
  • 和 Canvas / WebGL 天然融合:美颜、贴纸、弹幕合成可以直接在 VideoFrame 层做,不用截图再贴回去。

5.2 代价与现状

  • 音画同步要自己做:MSE 帮你做的时钟对齐,现在得手写——基于 AudioContext.currentTime 调度 VideoFrame 的渲染。
  • Safari 仍在路上:WebCodecs 在 Safari 16.4+ 才开始逐步支持,兼容性远不如 MSE。
  • iOS 是变量:iOS Safari 的 WebCodecs 实装进度比桌面慢,但一旦铺开,iPhone 原生播 FLV 将第一次成为可能——这是 MSE 方案十年来解不了的症结。
  • H.265/AV1 更香:WebCodecs 对新编码的接入速度远快于 MSE 容器生态,未来低码率直播会大量从这里出口。

5.3 过渡期建议

  • 主链路仍走 MSE:生态成熟、踩坑路径已经铺好,flv.js/mpegts.js 足够稳定。
  • 低延迟路径试点 WebCodecs:对延迟敏感的连麦观众路、云游戏、低延迟监控,先在支持度达标的浏览器上启用。
  • 做能力探测降级'VideoDecoder' in window + VideoDecoder.isConfigSupported({ codec: 'avc1.64001f' }) 双重判断,再决定走哪条链路。
  • 关注 <video>.srcObject = MediaStreamTrackGenerator 流派:WebCodecs 解出的 VideoFrame 可以喂给 MediaStreamTrackGenerator,再挂到 <video>.srcObject,复用原生播放器 UI,是个折中路线。

六、结语:一行 <video src> 背后的 2000 行 JS

回头看这条链路:fetch 流拉字节 → FLV demux 拆 Tag → 重打包成 fMP4 → MSE 喂 <video>,再叠上背压控制、断流重连、时间戳续接。每一步都有历史包袱,也都有明确的工程收益。

MSE 路线是过去十年直播前端的事实标准,flv.js/mpegts.js 是它最成熟的落地;WebCodecs 路线则是下一个十年的起点——它不会立刻取代 MSE,但会先在"延迟敏感 + 需要画面合成"的场景里撕开口子,等 Safari/iOS 跟上后完成接棒。

对播放器内核开发者来说,理解这条链路的每个环节,才知道线上出现"黑屏 0.5 秒""音画错开 200ms""iOS 必崩"这些问题时,该去哪一层定位。这比任何一个成熟库的 API 文档都更有价值。