使用SocketIO流式查看视频

更新时间: 2024-11-28 15:40:07

上一步查看了服务器上的图片,今天就玩点不一样的,用websocket来推服务器上的视频流

先看效果:
客户端选择了要看的视频以后就会通过socket将切分好的视频片段推过来,前端拼起来一段一段的播

# 思路

  • 服务端使用fs.createReadStream将视频文件切分成小块的二进制,推送给客户端
  • 客户端使用video标签 + MediaSource 来实现播放

# 前置知识

# Blob 和 ArrayBuffer

最早是数据库直接用Blob来存储二进制数据对象,这样就不用关注存储数据的格式了。

在web领域,Blob对象表示一个只读原始数据的类文件对象,虽然是二进制原始数据但是类似文件的对象,因此可以像操作文件一样操作Blob对象。

ArrayBuffer对象用来表示通用的、固定长度的原始二进制数据缓冲区。

我们可以通过new ArrayBuffer(length)来获取一片连续的内存空间,它不能直接读写,但可根据需要将其传递到TypedArray视图或者DataView对象来解释原始缓冲区。

实际上视图知识给你提供了一个某种类型的读写接口,让你可以操作ArrayBuffer里的数据。

TypedArray需要指定一个数组类型来保证数组成员都是同一个数据类型,而DataView数组成员可以是不同的数据类型。

TypedArray视图的类型数组对象有以下几个:

类型 对象 长度
Int8Array 8位有符号整数 长度1个字节
Uinit8Array 8位无符号整数 长度1个字节
Unit8ClampedArray 8位无符号整数 长度1字节,溢出处理不同
Int16Array 16位有符号整数 长度2字节
Unit16Array 16位无符号整数 长度2字节
Int32Array 32位有符号整数 长度4字节
Unit32Array 32位无符号整数 长度4字节
Float32Array 32位浮点数 长度4字节
Float64Array 64位浮点数 长度8字节

Blob与ArrayBuffer的区别是,除了原始字节以外它还提供了mime type作为元数据,Blob和ArrayBuffer之间可以进行转换

File对象其实继承自Blob对象,并提供了name,lastModifiedDate,size,type等基础元数据

创建Blob对象并转换成ArrayBuffer:

// 创建一个以二进制数据存储的html文件
const text = "<div>hello world</div>"
const blob = new Blob([text], {type: "text/html"})  // Blob {size:22, type:"text/html"}

//以文本读取
const textReader = new FileReader()  
textReader.readAsText(blob)
textReader.onload = function() {
    console.log(textReader.result) // <div>hello world</div>
}

// 以ArrayBuffer形式读取  
const bufReader = new FileReader()  
bufReader.readAsArrayBuffer(blob)  
bufReader.onload = function() {
    console.log(new Uint8Array(bufReader.result)) 
    // Uint8Array(22) [60, 100, 105, 118, 62, 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 60, 47, 100, 105, 118, 62]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

创建一个相同数据的ArrayBuffer,并转换成Blob:

// 我们直接创建一个Uint8Array并填入上面的数据  
const u8Buf = new Uint8Array([60, 100, 105, 118, 62, 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 60, 47, 100, 105, 118, 62]);
const u8Blob = new Blob([u8Buf], {type: "text/html"}) // Blob {size:22, type:"text/html"}
const textReader = new FileReader()  

textReader.readAsText(u8Blob)
textReader.onload = function() {
    console.log(textReader.result) // <div>hello world</div>
}
1
2
3
4
5
6
7
8
9

# URL.createObjectURL

video标签,audio标签还是img标签的src属性,不管是相对路径,绝对路径,或者一个网络地址,归根结底都是只想一个文件资源的地址。

既然我们知道了Blob其实是一个可以当做文件用的二进制数据,那么只要我们可以生成一个指向Blob的地址,是不是就可以用这些标签的src属性上?

答案肯定是可以的,这里我们要用到的就是URL.createObjectURL()

const objectURL = URL.createObjectURL(object)
//blob:http://localhost:1234/abcedfgh-1234-1234-1234-abcdefghijkl
1
2

这里的object参数是用于创建URL的File对象、Blob对象或者MediaSource对象,生成的连接就是以blob:开头的一段地址,表示指向的是一个二进制数据。

其中localhost:1234是当前网页的主机名称和端口号,也就是location.host, 而且这个Blob URL是可以直接访问的。

需要注意的事,即使是同样的二进制数据,每调用一次URL.createObjectURL方法,就会得到一个不一样的Blob URL。这个URL存在的时间,等同于网页的存在事件,一旦网页刷新或卸载,这个Blob URL就失效。

通过URL.revokeObjectURL(objectURL) 可以释放URL对象。当你结束使用某个URL对象之后,应该通过调用这个方法来让浏览器知道不用在内存中继续保留对这个文件的引用了,允许平台在合适的时机进行垃圾收集。

注意

如果是以文件协议打开的html文件,即url为file://开头,则地址中的http://localhost:1234会变成null,而且此时这个Blob URL是无法直接访问的。

# HLS 和 MPEG DASH

HLS(HTTP Live Streaming),是由Apple公司实现的基于HTTP的媒体流传输协议。HLS以ts为传输格式,m3u8为索引文件(文件中包含了索要用的ts文件名称,时长等信息,可以用播放器播放,也可以用vscode之类的编辑器打开查看),在移动端大部分浏览器都支持,也就是说你可以用video标签直接加载一个m3u8文件播放视频或直播,但是在pc端,除了苹果的Safari,需要引入库来支持。

用到此方案的视频网站比如优酷,可以在视频播放时通过调试查看Network里的xhr请求,会发现一个m3u8文件,和每隔一段时间请求几个ts文件。

但是除了HLS,还有Adobe的HDS,微软的MSS,方案一多就要有个标准点的东西,于是就有了MPEG DASH。

DASH(Dynamic Adaptive Straming over HTTP),是一种在互联网上传送动态码率的Video Streaming技术,类似于苹果的HLS,DASH会通过media presentation description(MPD)将视频内容切成一个很短的文件片段,每个切片都会有多个不同的码率,DASH Client可以根据网络的情况选择一个码率进行播放,支持在不同码率之间无缝切换。

Youtube,B站都是用这个方案。这个方案索引文件通常都是mpd文件(类似HLS的m3u8文件功能),传输格式推荐的是fmp4(Fragmented MP4),文件扩展名通常为.m4s或直接用.mp4。所以用调试查看b站视频播放时的网络请求,会发现每隔一段时间有几个m4s文件请求。

# MediaSource

虽然video很强大,但是还有很多功能video并不支持,比如直播,即使切换视频清晰度,动态更新音频语言等功能。

MSE(Media Source Extensions)就来解决这些问题,它是W3C的一种规范,如今大部分浏览器都支持。

它使用video标签价JS来实现这些复杂的功能。它将video的src设置为MediaSource对象,然后通过HTTP请求获取数据,然后传给MediaSource中的SourceBuffer来实现视频播放。

const video = document.querySelector('video')
const mediaSource = new MediaSource()

mediaSource.addEventListener('sourceopen', ({ target }) => {
    URL.revokeObjectURL(video.src)
    const mime = 'video/webm; codecs="vorbis, vp8"'
    const sourceBuffer = target.addSourceBuffer(mime) // target 就是 mediaSource
    fetch('/static/media/flower.webm')
        .then(response => response.arrayBuffer())
        .then(arrayBuffer => {
            sourceBuffer.addEventListener('updateend', () => {
                if (!sourceBuffer.updating && target.readyState === 'open') {
                    target.endOfStream()
                    video.play()
                }
            })
            sourceBuffer.appendBuffer(arrayBuffer)
        })
})

video.src = URL.createObjectURL(mediaSource)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

addSourceBuffer方法会根据给定的MIME类型创建一个新的SourceBuffer对象,然后会将它追加到MediaSouce的SourceBuffers列表中。

我们需要传入相关具体的编解码器字符串,这里第一个是音频(vorbis),第二个是视频(vp8), 两个位置也可以互换,知道了具体的编解码器浏览器就无需下载具体数据就知道当前类型是否支持,如果不支持该方法就会抛出NotSupportedError错误。更多关于媒体类型MIME编解码器可以参考RFC 4821 (opens new window)

这里还在一开始就调用了 revokeObjectURL。这并不会破坏任何对象,可以在MediaSource连接到video后随时调用。它允许浏览器在适当的时候进行垃圾回收。

视频并没有直接推送到MediaSource中,而是SourceBuffer,一个MediaSource中有一个或多个SourceBuffer。每个都与一种内容类型关联,可能是视频、音频、视频和音频等。

  • MediaSource 属性
属性 描述
sourceBuffers 返回包含MediaSource所有SourceBuffer的SourceBufferList对象
activeSourceBuffers 上个属性的子集,返回当前选择的视频轨道,启用的音频轨道和显示或隐藏的文本轨道
duration 获取或设置当前媒体展示的时长,负数或NaN时抛出InvalidAccessError,readyState不为open或SourceBuffer.updating属性为true时抛出InvalidStateError
readyState 表示MediaSource的当前状态
  • readyState有以下值:
含义
closed 未附着到一个media元素上
open 已附着到一个media元素并准备好接收SourceBuffer对象
ended 已附着到一个media元素,但流已被MediaSource.endOfStream()结束
  • MediaSource方法
方法 描述
addSourceBuffer(mime) 根据给定MIME类型创建一个新的SourceBuffer对象,将它追加到MediaSource的SourceBuffers列表中
removeSourceBuffer(sourceBuffer) 移除MediaSource中指定的SourceBuffer。如果不存在则抛出NotFoundError异常
endOfStream(endOfStreamError) 向MediaSource发送结束信号,接收一个DOMString参数,表示到达流末尾时将抛出的错误
setLiveSeekableRange(start,end) 设置用户可以跳跃的视频范围,参数是以秒为单位的Double类型,负数等非法参数会抛出异常
clearLiveSeekableRange 清除上次设置的LiveSeekableRange
  • 其中addSourceBuffer可能会抛出一下错误 :
错误 描述
InvalidAccessError 提交的mimeType是一个空字符串
InvalidStateError MediaSource.readyState的值不等于open
NotSupportedError 当前浏览器不支持的mimeType
QuotaExceededError 浏览器不能再处理SourceBuffer对象
  • endOfStream的参数可能是如下两种字符串:
参数 含义
network 终止播放并发出网络错误信号
decode 终止播放并发出解码错误信号

当MediaSource.readyState不等于open或有SourceBuffer.updating等于true,调用endOfSteam会抛出 InvalidStateError异常

它还有一个静态方法:

静态方法 描述
isTypeSupported(mime) 是否支持指定的mime类型,返回true表示可以能支持并不能保证
  • MediaSource事件
错误 描述
sourceopen readyState从closed或ended到open
sourceended readyState从open到ended
sourceclose readyState从open或ended到closed
  • SourceBuffer API
    SourceBuffer通过MediaSource将一块块媒体数据传递给 media 元素播放。

  • SourceBuffer属性

属性 描述
mode 控制处理媒体片段序列,segements片段时间戳决定播放顺序,sequence添加顺序决定播放顺序,在MediaSource.addSourceBuffer()中设置初始值,如果媒体片段有时间戳设置为segments,否则sequence。自己设置时只能从segments设置为sequence,不能反过来
updating 是否正在更新,比如appendBuffer()或remove()方法还在处理中
buffered 返回当前缓冲的TimeRanges对象
timestampOffset 控制媒体片段的时间戳偏移量,默认是0
audioTracks 返回当前包含的AudioTrack的AudioTrackList对象
videoTracks 返回当前包含的VideoTrack的VideoTrackList对象
textTracks 返回当前包含的TextTrack的TextTrackList对象
appendWindowStart 设置或获取append window的开始时间戳
appendWindowEnd 设置或获取append window的结束时间戳
  • SourceBuffer 方法
方法 描述
appendBuffer(source) 添加媒体数据片段(ArrayBuffer或ArrayBufferView)到SourceBuffer
abort 中断当前片段,重置段解析器,可以让updating变成false
remove(start,end) 移除指定范围的媒体数据
  • SourceBuffer事件
方法 描述
updatestart updating从false变为true时
update append或remove已经成功完成,updating从true变为false
updateend append或remove已经结束,在update之后触发
error append时发生了错误,updating从true变为false
abort append或remove被abort()方法中断,updating从true变为false

# Fragmented MP4

如果随便找一个mp4文件,使用上面的例子播放,就会发现播放不了,这是因为sourceBuffer接收两种类型的数据:

  1. Initialization Segment视频的初始片段,其中包含媒体片段序列进行解码所需的所有初始化信息。
  2. Meida Segment包含一部分媒体时间轴的打包和带时间戳的媒体数据。
    MSE需要使用fmp4(fragmented MP4)格式

通常我们使用的mp4文件是嵌套结构的,客户端必须要从头加载一个MP4文件,才能够完整播放,不能从中间一段开始播放。而Fragmented MP4,就如它的名字碎片mp4,是由一些列的片段组成,如果服务器支持byte-range请求,那么,这些片段可以独立的进行请求到客户端进行播放,而不需要加载整个文件。

使用FFmpeg转换需要设置一些参数:

ffmpeg -i video.mp4 -movflags empty_moov+default_base_moof+frag_keyframe video-fragmented.mp4
1

网上大部分的资料中转换时是不带default_base_moof这个参数的,虽然可以转换成功,但是经测试如果不添加此参数网页中MediaSource处理视频时会报错。

# 代码实现

# 客户端选择视频

还是跟之前查看图片一样,客户端连上之后就给客户端推送视频列表过去,然后客户端渲染好。

客户端选择视频之后,将视频文件名通过select事件传送给服务端:

function selectVideo(filename) {
    socket.emit('select', filename)
}
1
2
3

# 服务端切分对应视频

服务端接收到select事件传过来的文件名,开始切分对应文件并存储到数组中,视频流切分完之后,在readStream的end事件中,向客户端发送ready事件,通知客户端可以开始请求视频片段了。

io.on('connection', (socket) => {
    let steamList = []
    socket.emit('fileList', fileList)
    socket.on('select', (filename) => {
        console.log(path.resolve(__dirname,`./video/${filename}`))
        steamList = []
        const readStream = fs.createReadStream(path.resolve(__dirname,`./video/${filename}`))
        
        readStream.on('data', (chunk) => {
            steamList.push(chunk)
        });

        readStream.on('end', () => {
            socket.emit('ready',steamList.length)
        });
    })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 客户端准备

客户端准备好video元素,然后将video元素的src指定为MediaSource的URL,在MediaSource的sourceopen回调中,添加sourceBuffer







 










 










 
 






 
 
 
 


<template>
  <div class="main">
    <ul class="img-ul">
      <li v-for="item in fileList" @click="selectVideo(item)">{{item}}</li>
    </ul>
    <div class="img-wrapper">
      <video ref="videoEl" controls></video>
    </div>
  </div>
</template>

<script setup>
  import {ref, onMounted} from "vue"
  import {io} from  "socket.io-client/dist/socket.io.js" 
  let socket = null
  const fileList = ref([])
  const videoEl = ref(null)
  const mediaSource = new MediaSource();
  let sourceBuffer = null

  onMounted(() => {

    socket = io("http://192.168.2.122:3000")

    socket.on('fileList', (list) => {
      fileList.value = list
    })

    videoEl.value.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen);
  })

  function selectVideo(filename) {
    socket.emit('select', filename)
  }

  function sourceOpen() {
    const mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
    sourceBuffer = mediaSource.addSourceBuffer(mime);  
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

# 客户端请求视频块

客户端监听到ready事件后,ready事件带着视频的总分段数,然后客户端就从索引0开始请求,值得注意的是,需要在SourceBuffer的updating为false之后才能添加下一个片段。

  • 在ready事件中,将total记录下来,并将index归0,然后请求第一个视频流
  • 服务端会在data事件中发送刚刚请求的视频流,将视频流添加到SourceBuffer中
  • 然后在SourceBuffer的updateend时间回调中请求下一个视频流,如果不再此回调中的话,SourceBuffer.appendBuffer会报错
  • 第一个视频流添加好了以后就可以调用video的play方法自动播放







 
 






















 
 
 
 
 

 
 
 
 
 

 
 
 
 
 
 
 
 







  import {ref, onMounted} from "vue"
  import {io} from  "socket.io-client/dist/socket.io.js" 
  let socket = null
  const fileList = ref([])
  const videoEl = ref(null)
  const mediaSource = new MediaSource();
  let sourceBuffer = null
  let total = 0
  let index = 0

  onMounted(() => {

    socket = io("http://192.168.2.122:3000")

    socket.on('fileList', (list) => {
      console.log('fileList',list)
      fileList.value = list
    })

    videoEl.value.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen);
  })

  function selectVideo(filename) {
    socket.emit('select', filename)
  }

  function sourceOpen() {
    const mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
    sourceBuffer = mediaSource.addSourceBuffer(mime);  

    socket.on('data', (chunk) => {
      if(chunk){
        sourceBuffer.appendBuffer(chunk);
      }
    });

    socket.on('ready', (num) => {
      total = num 
      index = 0
      getVideo(index)
    })

    sourceBuffer.addEventListener("updateend",function() {
      console.log("update")
      index += 1
      if(index === 1) {
        videoEl.value.play()
      }
      getVideo(index)
    })
  }

  function getVideo(index) {
    socket.emit('queryVideo',index)
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

# 服务端传输视频流

queryVideo事件会带着index参数表示要请求第几个视频流,如果index大于总数就返回end事件,否则就正常的返回对应的视频流。


















 
 
 
 
 
 


io.on('connection', (socket) => {
    let steamList = []
    socket.emit('fileList', fileList)
    socket.on('select', (filename) => {
        console.log(path.resolve(__dirname,`./video/${filename}`))
        steamList = []
        const readStream = fs.createReadStream(path.resolve(__dirname,`./video/${filename}`))
        
        readStream.on('data', (chunk) => {
            steamList.push(chunk)
        });

        readStream.on('end', () => {
            socket.emit('ready',steamList.length)
        });
    })

    socket.on('queryVideo',(index) => {
        if(steamList.length < index + 1) {
            socket.emit('end')
        }
        socket.emit('data', steamList[index])
    })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 客户端监听end事件

 socket.on('end',() => {
    mediaSource.endOfStream()
    URL.revokeObjectURL(videoEl.value.src) // 这里不会立即销毁,window会在适合的时候垃圾回收  
})
1
2
3
4

# 后续可优化的部分

基本上原理就是这样,实际上我测试的时候发现长点的视频会报错,SouceBuffer满了不能再继续添加数据,后面有空还可以将视频精确的切分成多少秒1段,然后一边播一边添加,满了就不再继续添加,播放一部分之后就将前面的buffer删掉,然后继续添加。