npm install jsmpeg -s
单链路即:rtsp流=>ffmpeg转码(mpeg1+mp2)=>http server接收=>websocket server转发=>websocket client
首先贴上仓库中c语言的代码部分,如下图:
在根目录下有build.sh,用于编译出jsmpeg.min.js
在输出文件中,可以找到一段很长的字符串变量(WASM_BINARY_INLINED),这就是经过编译后的二进制c语言模块(经过base64加密压缩的)
这段内容在Player构造函数中被使用到,如下图:
至此,大致介绍完毕,相信看官们已经基本清楚jsmpeg的原理了
在前面的介绍中已经说到,本方案需要使用到一个websocket server来中转,jsmpeg作者已经为我们提供了一个简易websocket server端,即websocket-relay.js。
为方便测试,首先需要把jsmpeg的仓库拉取到本地,因为需要用到其中jsmpeg.min.js、websocket-relay.js文件
import JSMpeg from 'jsmpeg'
如果是使用源码:
import JSMpeg from 'xx/jsmpeg.min.js'
let player = new JSMpeg.Player('ws://xxxx',{ ... })
自动识别方式:
jsmpeg.js会自动识别docment中所有class包含jsmpeg的元素,并获取data-url属性后把该元素当做播放器容器,html如下
<div class="jsmpeg" data-url="ws://xxxx">div>
点此官方介绍
名称 | 类型 | 说明 |
---|---|---|
canvas | HTMLCanvasElement | 用于视频渲染的HTML Canvas元素。如果没有给出,渲染器将创建自己的Canvas元素。 |
loop | boolean | 是否循环播放视频(仅静态文件),默认=true |
autoplay | boolean | 是否立即开始播放(仅限静态文件),默认=false |
audio | boolean | 是否解码音频,默认=true |
video | boolean | 是否解码视频,默认=true |
poster | string | 预览图像的URL,用来在视频播放之前作为海报显示。 |
pauseWhenHidden | boolean | 当页面处于非活动状态时是否暂停播放,默认=true(请注意,浏览器通常会在非活动选项卡中限制 JS) |
disableGl | boolean | 是否禁用WebGL,始终使用Canvas2D渲染器,默认=false |
disableWebAssembly | boolean | 是否禁用WebAssembly并始终使用JavaScript解码器,默认=false(不建议设置为true) |
preserveDrawingBuffer | boolean | WebGL上下文是否创建必要的“截图” |
progressive | boolean | 是否以块的形式加载数据(仅静态文件)。当启用时,回放可以在完整加载源之前开始 |
throttled | boolean | 当不需要回放时是否推迟加载块。默认=progressive |
chunkSize | number | 使用时,以字节为单位加载的块大小。默认(1 mb)1024*1024 |
decodeFirstFrame | boolean | 是否解码并显示视频的第一帧,一般用于设置画布大小以及使用初始帧作为"poster"图像。当使用自动播放或流媒体资源时,此参数不受影响。默认true |
maxAudioLag | number | 流媒体时,以秒为单位的最大排队音频长度。(可以理解为能接受的最大音画不同步时间) |
videoBufferSize | number | 流媒体时,视频解码缓冲区的字节大小。默认的512 * 1024 (512 kb)。对于非常高的比特率,您可能需要增加此值。 |
audioBufferSize | number | 流媒体时,音频解码缓冲区的字节大小。默认的128 * 1024 (128 kb)。对于非常高的比特率,您可能需要增加此值。 |
名称 | 参数 | 说明 |
---|---|---|
onVideoDecode | decoder, time | 在每个解码和渲染的视频之后调用的回调 |
onAudioDecode | decoder, time | 在每个解码音频帧后调用的回调 |
onPlay | player | 当播放开始时调用的回调函数 |
onPause | player | 当播放暂停时调用的回调函数(例如当.pause()被调用或源结束时) |
onEnded | player | 当播放到达源的末尾时调用(只在loop=false调用) |
onStalled | player | 当没有足够的数据播放时调用的回调 |
onSourceEstablished | source | 当source第一次接收到数据时调用的回调 |
onSourceCompleted | source | 当源接收到所有数据时调用的回调 |
名称 | 参数 | 说明 |
---|---|---|
play | none | 开始播放 |
pause | none | 暂停播放 |
stop | none | 停止播放,并跳到视频开头 |
nextFrame | none | 一个视频帧的高级回放。这并不解码音频。当没有足够的数据时,返回成功 |
destroy | none | 停止回放,断开源并清理WebGL和WebAudio状态。player不能被再使用。 |
volume | number | 获取或设置音频音量(0-1) |
currentTime | number | 获取或设置当前播放位置,以秒为单位 |
paused | boolean | 只读,无论回放是否暂停 |
(1)确保你有可用的rtsp流,我是有摄像机可以测试的,如果没有摄像机,可以使用ffmpeg推视频文件流,或者去网上找找免费的电视台rtsp流,也可以用一些软件推你电脑的桌面流(不过一般的软件都只能推rtmp流)
(2)运行websocket server,打开cmd,cd到websocket-relay.js所在目录(注:此步骤需要安装node.js环境,请百度)- 以下步骤推荐大家在VSCode中操作
(3)启用ffmpeg,拉取指定流转码后推给http server
ffmpeg -rtsp_transport tcp -i rtsp://[用户名]:[密码]@[ip]:554/h264/ch1/main/av_stream -q 0 -f mpegts -codec:v mpeg1video -s 1280x720 -b:v 1500k -codec:a mp2 -ar 44100 -ac 1 -b:a 128k http://127.0.0.1:8890/test
ffmpeg -rtsp_transport tcp -i rtsp流地址 -q 0 -f mpegts -codec:v mpeg1video -s 1280x720 -b:v 1500k -codec:a mp2 -ar 44100 -ac 1 -b:a 128k http://127.0.0.1:8890/test
ffmpeg -f gdigrab -i desktop -q 0 -f mpegts -codec:v mpeg1video -s 1280x720 -b:v 1500k -codec:a mp2 -ar 44100 -ac 1 -b:a 128k http://127.0.0.1:8890/jsmpeg
运行后,ffmpeg控制台如下图:
http server控制台中可以看到一个客户端连入,此后http server会把接受到的流数据转发给所有连入ws://127.0.0.1:8891的客户端:
(4)web中使用jsmpeg.js接收流并播放
DOCTYPE html>
<html>
<head>
<title>JSMpeg TESTtitle>
<meta name="viewport" content="width=device-width">
head>
<body>
<div class="content">
<h1>JSMpeg 测试 (为了播放声音,请先点击页面一次)div>
<canvas id="jsmpeg-canvas">canvas >
div>
<script type="text/javascript" src="jsmpeg.min.js">script>
<script type="text/javascript">
document.addEventListener('click', () => {
let player = new JSMpeg.Player("ws://127.0.0.1:8891",{
canvas: document.getElementById('jsmpeg-canvas'),
// 要在用户点击过页面后,才可以播放声音
// audio: false,
})
}, { once: true })
script>
body>
html>
将jsmpeg使用到项目中的话,原生功能可能还是稍微不够的,需要对其二次开发
为了方便二次开发,建议各位将jsmpeg.min.js拆分为多个独立模块,在vscode中打开此文件,序列化之后,可以看到都已经分类好了,接下来将各个模块独立为js文件;
拆分后如下图:
各模块说明:
拆分后的源码见git
/**
* 旋转画布
* @param {number} angle 角度
* @param {boolean} append 是否为追加角度
* @returns
*/
rotate(angle, append = false) {
if (!this.canvas || typeof angle !== 'number') return
const canvas = this.canvas
angle = append ? this.store.canvasAngle + angle : angle
angle = angle >= 360 ? angle - 360 : angle <= -360 ? angle + 360 : angle
if ((Math.abs(angle) / 90) % 2 === 1) {
// 如果是90整数倍,表示为垂直状态
const containerBound = this.contianer.getBoundingClientRect(),
canvasBound = canvas.getBoundingClientRect()
if (canvas.width > canvas.height) {
// 宽>高,取容器高度作为canvas最大宽度
canvas.style.width = containerBound.height + 'px'
} else {
// 宽<=高,取容器宽度作为canvas最大高度
canvas.style.height = containerBound.width + 'px'
}
} else {
canvas.style.width = null
canvas.style.height = null
}
canvas.style.transform = `rotate(${angle}deg)`
this.store.canvasAngle = angle
}
/**
* 截图
* @param {string} name
*/
snapshot(name = 'JSMPeg') {
if (this.canvas) {
const mime = 'image/png',
url = this.canvas.toDataURL(mime)
saveToLocal(url.replace(mime, 'image/octet-stream'), `${name}_截图_${new Date().toLocaleTimeString()}.png`, mime)
}
}
// saveToLocal方法如下
/**
*
* @param {object} param
* @param {string|object|Array} param.data 数据,传入后url参数将被忽略
* @param {string} param.url 文件下载地址
* @param {string} param.name 文件名称
* @param {string} param.mimeType 文件mime类型
* @returns
*/
export function saveToLocal(blob, name = 'JSMpeg_' + Date.now(), mimeType = '') {
if (!blob) return
const a = document.createElement('a')
a.style.display = 'none'
a.download = name
if (typeof blob === 'string') {
a.href = blob
} else {
blob =
blob instanceof Blob
? blob
: new Blob(blob instanceof Array ? blob : [blob], {
type: mimeType
})
a.href = URL.createObjectURL(blob)
}
setTimeout(() => {
a.click()
}, 0)
setTimeout(() => {
a.remove()
}, 1)
if (blob instanceof Blob) {
setTimeout(() => {
URL.revokeObjectURL(blob)
}, 1000)
}
}
recorder = {
/** 录制持续时间 */
duration: 0,
timer: null,
/** @type {'canvas'|'ws'} */
mode: '',
running: false,
saveName: '',
/**
* @type {MediaRecorder}
* 媒体录制器
*/
mediaRecorder: null,
/**
* @type {MediaStream}
* 视频流
*/
stream: null,
/**
* @type {ArrayBuffer[]}
* 视频数据块
*/
chunks: null,
startTiming() {
this.duration = 0
this.timer = setInterval(() => {
this.duration += 1
}, 1000)
},
pauseTiming() {
clearInterval(this.timer)
this.timer = null
},
continueTiming() {
this.timer = setInterval(() => {
this.duration += 1
}, 1000)
},
stopTiming() {
this.pauseTiming()
this.duration = 0
},
clear() {
this.running = false
this.mediaRecorder = null
this.stream = null
this.chunks = null
}
}
/**
* 视频录制
* @param {string} name
* @param {'ws'|'canvas'} mode
*/
recording(name = 'JSMpeg', mode = 'ws') {
try {
if (!this.isPlaying) {
return
}
if (this.isRecording || this.recorder.stream) {
// 停止录制
this.recorder.stopTiming()
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStream_Recording_API
if (this.recorder.mode === 'canvas' && this.recorder.mediaRecorder && this.recorder.stream instanceof MediaStream) {
this.recorder.mediaRecorder.stop()
saveToLocal(this.recorder.chunks, `${this.recorder.saveName}.webm`, 'video/webm;codecs=vp9')
} else if (this.recorder.mode === 'ws' && this.recorder.chunks instanceof Array) {
saveToLocal(this.recorder.chunks, `${this.recorder.saveName}.ts`, 'video/MP2T')
this.source.recorder = undefined
}
this.recorder.clear()
this.recorder.running = false
} else {
// 开始录制
if (mode === 'canvas') {
// 此方法兼容性较差,captureStream、MediaRecorder好像都是新规范,有些浏览器不支持
let chunks = []
/** @type {MediaStream} */
let stream = this.canvas?.captureStream(25)
let mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
})
mediaRecorder.ondataavailable = (e) => {
chunks.push(e.data)
}
mediaRecorder.start()
this.recorder.mediaRecorder = mediaRecorder
this.recorder.chunks = chunks
this.recorder.stream = stream
this.recorder.mode = 'canvas'
this.recorder.running = true
// this.recording.recorder = new MediaRecorder(this.recording.stream)
} else if (mode === 'ws') {
// 服务端转发过来的流就是ffmpeg已转码的ts视频流数据,所以在websocket收到数据的时候,存放到数组中即可实现录制
this.recorder.chunks = []
this.recorder.chunks.write = function(data) {
this.push(data)
}
this.source.recorder = this.recorder.chunks
this.recorder.mode = 'ws'
this.recorder.running = true
} else return
this.recorder.saveName = `${name}_录制_${new Date().toLocaleTimeString()}`
this.recorder.startTiming()
}
} catch (error) {
console.error(error)
}
}
为方便项目使用,需要将jsmpeg封装为vue组件,源码就不贴在文章中了,见此处,组件介绍如下:
名称 | 类型 | 说明 |
---|---|---|
url | string | 视频流地址 |
title | string | 播放器标题 |
options | object | jsmpeg原生选项,直接透传 |
closeable | boolean | 是否可关闭(单击关闭按钮,仅抛出事件) |
in-background | boolean | 是否处于后台,如el-tabs的切换,路由的切换等 |
show-duration | boolean | 是否现实持续播放时间 |
default-mute | boolean | 默认静音 |
with-toolbar | boolean | 是否需要工具栏 |
名称 | 参数 | 说明 |
---|---|---|
包含所有jsmpeg原生事件 | 看2.1.2小节或具体看jsmpeg官方文档 | |
onSourceConnected | 无 | 当websocket连接上服务端时触发 |
onSourceStreamInterrupt | 无 | 当websocket超过一定时间没有收到流时触发 |
onSourceStreamContinue | 无 | 当onSourceStreamInterrupt触发后websocket第一次接收到流时触发 |
onSourceClosed | 无 | 当websocket关闭后触发 |
onResolutionDecode | width,height | 当获取到视频分辨率后触发 |
由于后端不是我做,提供后端java思路仅供参考
我们知道websocket和http一样是支持子目录形式的url的,那么这个子目录我们可以用摄像机的主键来定义
这里建议将http server和websocket server封装到一个服务类中,这样在http server收到ffmpeg推过来的流之后可以直接调用websocet server转发给client
PS: 使用ws多路传输时,后台需要优化转发处理,否则可能出现多链路混流现象,导致斑马纹或视频流异常
2022-03-09: 公网下推荐使用开源库ZLMediaKit
2022-07-27: 封装为vue组件并发布到了npm上,可直接安装使用,详见仓库,npm