背景
项目里有这样一个场景,用户输入文字,请求接口获取视频并播放,由于Demo阶段接口返回的是完整视频的MP4链接,导致每次切换视频源都会闪烁,体验极差,想着优化这个视频切换的过程,接近无感。
方案
利用Media Source Extensions这个特性,初始化一个blob链接,分段请求MP4流,喂给这个MSE下的SourceBuffer,每次切换视频则清空当前SourceBuffer,重新填充。
缺陷
MSE兼容性不太好,主要是IOS,目前只有IPadOS Safari 13部分支持,其他浏览器均不支持,所以在IOS目前还没有优化方案,大佬们麻烦支个招。
(2022.08.10 项目中暂时采用重叠video的方式,每次切换视频源待视频加载完毕后切换到对应的DOM,避开加载视频期间的黑屏)
实现
话不多说,记录一下实现过程
主要有三个步骤(如果你的视频资源是FMP4,那么前两步是不需要的)
- 分段请求MP4资源,利用Range
- 将MP4资源转化为FMP4(MSE不支持unfragment MP4)
- 创建MSE,初始化SourceBuffer,将FMP4 append 到SourceBuffer上
分段请求MP4
声明一个Reader类,用于分段请求,提供一个stop和resume方法,因为后续要利用MP4Box来转化FMP4,需要先读取部分MP4配置做初始化,而后resume继续请求后续片段资源,Reader部分代码如下
export enum STATUS {
READING = 'reading',
PAUSE = 'pause',
STOP = 'stop',
NONE = 'none',
}
export interface ExArrayBuffer extends ArrayBuffer {
fileStart?: number
}
export interface ReaderOption {
chunkStart?: number
chunkSize?: number
total?: number
onChunk?: (e: { bytes: ExArrayBuffer; isEof: boolean; total: number }) => void
}
type SuccessCB = (end: number, bytes: ExArrayBuffer, total: number) => void
// TODO 是否做缓存
export class Reader {
url: string
status: string
fetcher: AbortController | null
constructor(public onChunk: Function, public chunkStart = 100 * 1024, public chunkSize = 300 * 1024, public total = 0) {
this.status = STATUS.NONE
this.fetcher = null
this.url = ''
}
// 开始请求
start(url: string) {
this.url = url
this.chunkStart = 0
this.total = this.chunkStart + this.chunkSize - 1
this.fetcher = null
this.status = STATUS.READING
this.loadchunk()
}
// 重启请求
resume() {
if (this.status === STATUS.READING) return
this.status = STATUS.READING
this.loadchunk()
}
// 中止请求
stop() {
this.status = STATUS.STOP
if (!this.fetcher) return
this.fetcher.abort()
this.fetcher = null
}
// 读取片段
loadchunk() {
if (this.fetcher) return
if (this.chunkStart === this.total - 1) return
this.fetcher = new AbortController()
this.read((end: number, bytes: ExArrayBuffer, total: number) => {
const chunkStart_cache = this.chunkStart
this.chunkStart = end
this.total = total
this.fetcher = null
bytes.fileStart = chunkStart_cache
const isEof = end === total - 1
if (isEof) this.status = STATUS.STOP
this.onChunk({ bytes, isEof, total })
if (this.status === STATUS.READING) {
this.loadchunk()
}
})
}
// 发起请求
read(onSuccess: SuccessCB) {
const end = this.chunkStart + this.chunkSize - 1 > this.total ? this.total : this.chunkStart + this.chunkSize - 1
if (this.chunkStart === this.total) return
fetch(this.url, {
headers: { Range: `bytes=${this.chunkStart}-${end}` },
signal: this.fetcher?.signal,
})
.then(async (res) => {
const [, , end, total] = res.headers.get('Content-Range')?.match(/(\d+)-(\d+)\/(\d+)/) as RegExpMatchArray
onSuccess(parseInt(end, 10), await res.arrayBuffer(), parseInt(total, 10))
})
.catch((e) => {
console.log(e)
})
}
}
封装MP4Box
MP4Box可以接受分片的MP4资源,组装成Fragment,组装完成后喂给SourceBuffer
import MP4Box from 'mp4box'
import { Reader } from './reader'
import type { ExArrayBuffer } from './reader'
export class FetchFMp4 {
MF: any
Mp4Info: any
reader: Reader
SourceBuffers: Array<{ codec: string; sourceBuffer: SourceBuffer }>
constructor(public MS: MediaSource, public el: HTMLVideoElement, public onComplete?: Function) {
this.SourceBuffers = []
this.Mp4Info = null
this.reader = new Reader(this.onChunk.bind(this))
}
// 初始化MF 开始拉取文件
fetchFile(url: string) {
try {
this.el.pause()
this.SourceBuffers.forEach((s) => {
s?.sourceBuffer.remove(0, this.el.duration)
})
} catch (error) {
console.log('sourceBuffer is empty')
}
this.MF = MP4Box.createFile()
this.MF.onReady = this.onReady.bind(this)
this.MF.onSegment = this.onSegment.bind(this)
this.MF.reader = this.reader
this.MF.reader.start(url)
}
// 读取文件配置完毕,开始组装
onReady(info: any) {
this.Mp4Info = info
this.MS.duration = info.duration / info.timescale
this.initSourceBuffers()
this.initTracks()
this.initializeSegmentation()
}
// 每组装一个,触发该方法,装进MSE
onSegment(id: string, ctx: any, buffer: ExArrayBuffer, sampleNum: number, isEnd: boolean) {
ctx.pending.push({
id: id,
buffer: buffer,
sampleNum: sampleNum,
isEnd: isEnd,
})
this.updateEnd(ctx)
}
// 读取每一个chunk完毕,如果还未结束,则继续拉取
onChunk(chunk: { bytes: ExArrayBuffer; isEof: boolean }) {
const next = this.MF.appendBuffer(chunk.bytes, chunk.isEof)
if (chunk.isEof) {
this.MF.flush()
} else {
this.MF.reader.chunkStart = next
}
}
// 初始化sourceBuffer,正常为视频和音频各一个
initSourceBuffers() {
if (!this.SourceBuffers.length) {
this.Mp4Info.tracks.forEach((track: any) => {
const mime = `video/mp4; codecs="${track.codec}"`
if (!MediaSource.isTypeSupported(mime)) {
throw new Error('MSE does not support: ' + mime)
}
const sourceBuffer = this.MS.addSourceBuffer(mime)
this.SourceBuffers.push({
codec: track.codec,
sourceBuffer,
})
})
}
}
// 初始化轨道
initTracks() {
const trackLen = this.Mp4Info.tracks.length
const shared = {
loading: false,
notEndCnt: trackLen,
pendingInitCnt: trackLen,
reader: this.MF.reader,
isMseEnd: false,
}
this.Mp4Info.tracks.forEach((track: any) => {
this.setSegmentOptions(track, shared)
})
}
// 初始化MF segment
initializeSegmentation() {
this.MF.initializeSegmentation().forEach((seg: any) => {
const ctx = seg.user
if (ctx.sourceBuffer.updating) {
ctx.pending.push({ isInit: true, buffer: seg.buffer })
} else {
ctx.sourceBuffer.appendBuffer(seg.buffer)
ctx.pending.push({ isInit: true })
}
})
}
// 设置轨道segment
setSegmentOptions(track: any, shared: any) {
const sourceBuffer = this.SourceBuffers.find((item) => item.codec === track.codec)!.sourceBuffer
const ctx = {
sourceBuffer,
id: track.id,
pending: [],
shared,
}
sourceBuffer.onerror = (e) => console.error(e)
sourceBuffer.onupdateend = () => this.updateEnd(ctx)
this.MF.setSegmentOptions(track.id, ctx)
}
// 注入sourceBuffer完成
updateEnd(ctx: any) {
if (ctx.sourceBuffer.updating || this.MS.readyState !== 'open') return
const seg = ctx.pending.shift()
if (seg && seg.isInit) {
ctx.shared.pendingInitCnt--
if (seg.buffer) {
ctx.sourceBuffer.appendBuffer(seg.buffer)
}
}
if (ctx.shared.pendingInitCnt === 0 && !ctx.shared.loading) {
ctx.shared.loading = true
this.loadMediaData()
return
}
if (ctx.isEof) {
ctx.shared.notEndCnt--
}
if (ctx.shared.notEndCnt === 0 && !ctx.shared.isMSEnd) {
if (ctx.sampleNum) {
this.MF.releaseUsedSamples(ctx.id, ctx.sampleNum)
ctx.sampleNum = null
}
ctx.shared.isMSEnd = true
this.onComplete && this.onComplete()
// if (this.SourceBuffers.every((item) => !item.sourceBuffer.updating)) {
// this.MS.endOfStream()
// this.onComplete && this.onComplete()
// }
this.el.currentTime = 0
this.el.play()
}
if (seg && !seg.isInit) {
ctx.sampleNum = seg.sampleNum
ctx.isEof = seg.isEnd
ctx.sourceBuffer.appendBuffer(seg.buffer)
}
}
// 读取mp4文件配置
loadMediaData() {
this.MF.reader.chunkStart = this.MF.seek(0, true).offset
this.MF.start()
this.MF.reader.resume()
}
}
MSE
最后一步,按照官方的Demo做MSE的初始化,接收ArrayBuffer即可
import { FetchFMp4 } from './transform'
const wURL = window.URL || webkitURL
export default class MSE {
MS: MediaSource
MF: FetchFMp4 | null
constructor(
public onComplete?: Function,
public el = document.querySelector('video'),
public mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
) {
if (!this.el) throw new Error('error')
this.MS = new MediaSource()
this.el.src = wURL.createObjectURL(this.MS)
this.MS.onsourceopen = () => this.onSourceOpen()
this.MF = null
}
static isSupport(mimeCodec?: string): boolean {
if (!mimeCodec) return !!window.MediaSource
return window.MediaSource && MediaSource.isTypeSupported(mimeCodec)
}
onSourceOpen() {
wURL.revokeObjectURL(this.el!.src)
this.MS.onsourceopen = null
// 配置MP4Box
this.MF = new FetchFMp4(this.MS, this.el!, this.onComplete)
}
// 注入资源
async append(file: string) {
this.MF && this.MF.fetchFile(file)
}
}
总结
MSE的使用相对简单,就是文档比较少,兼容性也比较差。
比较难的部分还是在MP4 -> FMP4。
由于接触音视频的场景比较少,对音视频编解码相关的知识比较薄弱,相关的文档也比较少。
总结一下,如果你也遇到类似的问题,可以考虑这个方案。
目前只是实现功能,还没有做相应优化,比如缓存、SourceBuffer append的位置、支持不同codec类型的音视频等。