MSE + fetch 流如何把 FLV 喂进 <video>
更新: 5/4/2026 字数: 0 字 时长: 0 分钟
一、一个尴尬的事实:<video> 不认 FLV
打开浏览器控制台,直接写:
<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 的
moovbox 要求先知道整段时长与索引,不适合直播这种无限流。就算用 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 最小可用骨架
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 Script | onMetaData(宽高、帧率、时长) | AMF0/AMF3 对象 |
0x09 Video | VideoTagHeader + AVCPacket | 首包是 AVCDecoderConfigurationRecord(SPS/PPS),之后是 NALU |
0x08 Audio | AudioTagHeader + AACPacket | 首包是 AudioSpecificConfig,之后是 AAC Raw |
实战要点:
- SPS/PPS 必须截取:直接拿
AVCDecoderConfigurationRecord里的 SPS/PPS 拼 fMP4 的avcCbox;不要尝试自己从 NALU 流里凑。 - NALU 长度前缀要改:FLV 里是 AVCC 格式(4 字节长度前缀),fMP4
mdat也要 AVCC——看似一样,但要确保长度字段是 big-endian 4 字节,别漏转。 - 时间戳的坑:FLV 的
timestamp是毫秒、24 位 + 8 位扩展,拼成 32 位有符号。直播跑过 24 天会翻转,需要做环绕处理。Video Tag 还有CompositionTime,pts = 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 队列
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 做不到流式消费:
XMLHttpRequest的responseType = 'arraybuffer'要等请求结束才能拿到数据;responseType = 'text'可以拿到增量,但是字符串,二进制会被编码破坏。用 XHR 玩 FLV 只能退化成"分段拉",失去长连接优势。 - fetch + ReadableStream 才是正解:
res.body.getReader()返回Uint8Array分片,真·流式,天然适配 demuxer。
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。实战策略:
- 用"SourceBuffer 水位"反向控速:append 前检查
sb.buffered.end - video.currentTime,超过阈值(比如 10 秒)就停止reader.read(),让 TCP 天然回压。 - 活跃驱逐:定期
sb.remove(0, currentTime - 5),直播不需要保留历史,缓冲区维持在 5–15 秒窗口最舒服。 - 追帧:观众长时间挂后台,回来时
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 文档都更有价值。