├── ffmpeg.sh ├── package.json ├── .gitignore ├── README.md ├── server.js ├── note.md ├── index.html └── mediaserver.js /ffmpeg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ffmpeg -f lavfi -re -i color=black:s=640x480:r=15 -filter:v "drawtext=text='%{localtime\:%T}':fontcolor=white:fontsize=80:x=20:y=20" \ 4 | -vcodec libx264 -tune zerolatency -preset ultrafast \ 5 | -g 15 -keyint_min 15 -profile:v baseline -level 3.0 -pix_fmt yuv420p -r 15 -f flv rtmp://39.106.248.166/live/live 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtmp-to-webrtc", 3 | "version": "1.0.0", 4 | "description": "rtmp to webrtc", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/notedit/rtmp-to-webrtc.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/notedit/rtmp-to-webrtc/issues" 17 | }, 18 | "homepage": "https://github.com/notedit/rtmp-to-webrtc#readme", 19 | "dependencies": { 20 | "body-parser": "^1.18.2", 21 | "execa": "^1.0.0", 22 | "fluent-ffmpeg": "^2.1.2", 23 | "get-port": "^4.0.0", 24 | "medooze-media-server": "^0.68.2", 25 | "node-media-server": "^1.3.0", 26 | "semantic-sdp": "^3.4.0", 27 | "string-format": "^2.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | 61 | *.mp4 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rtmp-to-webrtc 2 | 3 | 基于RTMP-CDN和WebRTC的低延迟(500ms以内)直播系统 4 | 5 | 6 | ### 观看效果 7 | 8 | 9 | https://rtmp-to-webrtc.dot.cc 10 | 11 | demo 部署在个人测试服务器上, 带宽有限, 如果挂了请通知我. 12 | 13 | 14 | ### 如何工作 15 | 16 | - RTMP推流到CDN上, 需要进行编码参数和gop的参数调优 17 | - 边缘节点部署webrtc服务器 18 | - 用户访问一路视频流的时候, 边缘节点webrtc服务器去CDN进行拉流 19 | - 把rtmp流转封装为rtp, 喂给webrtc服务器 20 | 21 | 22 | 23 | ### RTMP推流脚本 24 | 25 | 推流部分使用ffmpeg 26 | ``` 27 | ffmpeg -f lavfi -re -i color=black:s=640x480:r=15 -filter:v "drawtext=text='%{localtime\:%T}':fontcolor=white:fontsize=80:x=20:y=20" \ 28 | -vcodec libx264 -tune zerolatency -preset ultrafast -bsf:v h264_mp4toannexb -g 15 -keyint_min 15 -profile:v baseline -level 3.0 \ 29 | -pix_fmt yuv420p -r 15 -f flv rtmp://39.106.248.166/live/live 30 | 31 | ``` 32 | 33 | 34 | 35 | ### RTMP转封装RTP 36 | 37 | 此部分使用了gstreamer, 只所以用gstreamer是因为发现ffmpeg的转出来的rtp包, 有一定概率webrtc会解析失败, 还未找到具体原因 38 | ``` 39 | gst-launch-1.0 -v rtmpsrc location=rtmp://localhost/live/{stream} ! flvdemux ! h264parse ! \ 40 | rtph264pay config-interval=-1 pt={pt} ! udpsink host=127.0.0.1 port={port} 41 | 42 | ``` 43 | 44 | 45 | ### 一些数据 46 | 47 | 服务端部署在阿里云上, 延迟在1000毫秒内, gstreamer的转封装引入了300ms-500ms延迟(目测, 还没验证). 48 | 优化后整体延迟可以在500ms以内. 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const NodeMediaServer = require('node-media-server'); 2 | const express = require('express'); 3 | const bodyParser = require('body-parser'); 4 | const ffmpeg = require('fluent-ffmpeg'); 5 | const MediaServer = require('./mediaserver'); 6 | 7 | const app = express(); 8 | 9 | // need change is ip address 10 | const mediaserver = new MediaServer('127.0.0.1'); 11 | 12 | 13 | app.use(bodyParser.json()); 14 | app.use(bodyParser.urlencoded({ extended: true })); 15 | 16 | app.use(express.static('./')); 17 | 18 | 19 | const baseRtmpUrl = 'rtmp://127.0.0.1/live/'; 20 | 21 | app.get('/test', async (req, res) => { 22 | res.send('hello world') 23 | }) 24 | 25 | app.post('/watch/:stream', async (req, res) => { 26 | 27 | console.log('request body', req.body); 28 | 29 | let stream = req.params.stream; 30 | let offer = req.body.offer; 31 | 32 | // // If we did handle the stream yet 33 | if (!mediaserver.getStream(stream)) { 34 | await mediaserver.createStream(stream, baseRtmpUrl + stream); 35 | } 36 | 37 | let answer = await mediaserver.offerStream(stream, offer); 38 | console.log('answer', answer); 39 | res.json({answer:answer}); 40 | }) 41 | 42 | app.listen(4001, function () { 43 | console.log('Example app listening on port 4001!\n'); 44 | console.log('Open http://localhost:4001/'); 45 | }) 46 | 47 | const config = { 48 | rtmp: { 49 | port: 1935, 50 | chunk_size: 1024, 51 | gop_cache: true, 52 | ping: 60, 53 | ping_timeout: 30 54 | } 55 | }; 56 | 57 | 58 | const nms = new NodeMediaServer(config) 59 | 60 | nms.on('postPublish', (id, StreamPath, args) => { 61 | console.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 62 | 63 | }); 64 | 65 | nms.on('donePublish', (id, StreamPath, args) => { 66 | console.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 67 | 68 | let stream = StreamPath.split('/')[2] 69 | 70 | if(mediaserver.getStream(stream)) { 71 | mediaserver.removeStream(stream); 72 | } 73 | 74 | }); 75 | 76 | 77 | 78 | nms.run(); 79 | 80 | 81 | // now we need simulate a rtmp stream 82 | 83 | /* 84 | 85 | ffmpeg -f lavfi -re -i color=black:s=640x480:r=15 -filter:v "drawtext=text='%{localtime\:%T}':fontcolor=white:fontsize=80:x=20:y=20" -vcodec libx264 -tune zerolatency -preset ultrafast -g 15 -keyint_min 15 -profile:v baseline -level 3.0 -pix_fmt yuv420p -r 15 -f flv rtmp://localhost/live/live 86 | 87 | */ 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /note.md: -------------------------------------------------------------------------------- 1 | ** 基于RTMP和WebRTC开发大规模低延迟(1000毫秒内)直播系统 2 | 3 | 4 | **** 问题 5 | 6 | 随着移动设备大规模的普及以及流量的资费越来越便宜, 超低延迟的场景越来越多. 从去年到今年火过的场景就有在线娃娃机, 直播答题, 在线K歌等. 但要做到音视频的超低延迟确是很不容易, 编码延迟, 网络丢包, 网络抖动, 多节点relay,视频分段传输,播放端缓存等等都会带来延迟. 7 | 8 | 9 | **** WebRTC兴起提供的方案以及遇到的问题 10 | 11 | WebRTC技术的兴起为低延迟音视频传输带来了解决方案, 但WebRTC是为端到端设计的, 适合的场景是小规模内的实时互动, 例如视频会议, 连麦场景. 即使加入了SFU Media server作为转发服务器, 也很难做到大规模的分发. 另外一个需要考量的是流量成本, WebRTC的实时流量是通过UDP传输的(某些情况下可以用TCP), 无法复用在传统CDN的架构之上, 实时的流量价格更是CDN流量的3倍以上, 部署一个超低延迟的直播网络成本非常高. 12 | 13 | 14 | **** RTMP系统推流播放延迟分析 15 | 16 | 一个经过优化的RTMP-CDN网络端到端的延迟大概在1-3秒, 延迟大一些要在5秒甚至10秒以上. 从推流到播放, 会引入延迟的环节有编码延迟, 网络丢包和网络抖动, 视频的分段传输, 多媒体节点的relay, 播放器的缓存等等. 实际上除了网络丢包和网络抖动不太可控之外, 其他的各各环节都有一定的优化方案, 比如使用x264的-preset ultrafast和zerolatency, 可以降低编码的延迟, 17 | 分段传输部分可以把GOP减少到1秒之内, 在播放器端可以适当减小buffer, 并设置一定的追帧策略, 防止过大的buffer引起的时延. 18 | 19 | 20 | **** 低成本的低延迟的实现 21 | 22 | 23 | 在RTMP直播系统中从推流端到网络传输到播放器都做深度定制确实可以做到比较低的延迟, 但成本也是比较高的, 需要完备的高水平的团队(服务端和客户端), 以及大量的带宽服务器资源. 如果想做到超低延迟(1000毫秒以内)更是难上加难, 而且这么低的延迟也会带来一些负面的效果, 网络出现少许抖动的时候就会出现卡顿等等. 有没有更低成本的实现方案呢? 以及如何复用现有的CDN的基础设施来做到低延迟? 其实我们可以在现有的RTMP-CDN系统上做一些优化调整, 在边缘节点把RTMP流转化为WebRTC可以播放的流来达到低延迟和CDN系统的复用, 同时还可以利用WebRTC抗丢包来优化最后一公里的观看体验. WebRTC在各个平台上都有相应的SDK, 尤其是在浏览器内嵌, 可以极大的减少整个系统的开发, 升级, 维护成本, 达到打开浏览器就可以观看的效果. 24 | 25 | 26 | **** 需要注意的问题 27 | 28 | 当然事情不可能那么完美, The world is a bug. 让RTMP和WebRTC可以很好的互通也需要做一些额外的工作: 29 | 30 | 31 | 1, RTMP推流端低延迟以及GOP大小 32 | 33 | 如果想做到低延迟, 我们需要在推流端尽可能的快, 同时RTMP-CDN一般都会有GOP cache, 会缓存最近的一个GOP, GOP太大是没法做到低延迟的, 可以考虑把GOP设置在1秒. 这样的好处还有一个就是在WebRTC播放端, 如果出现丢关键帧的情况可以快速回复. 在我们这个场景下WebRTC服务端会拒绝WebRTR的FIR信息, 通过下一个关键帧来解决关键帧丢失的问题. 34 | 35 | 36 | 2, RTMP源站以及边缘站尽可能的不做任何缓存 37 | 38 | 在一个帧率为25FPS的直播流中, 缓存一帧就会增加40ms的延迟. 在我们这个场景下RTMP的源站和边缘站除了做一些GOP cache外, 其他缓存要尽可能的小. 39 | 40 | 41 | 3, 编码器参数设置 42 | 43 | WebRTC对H264的支持还没有那么完美, 比如在chrome支持H264的baseline, main profile 以及high profile, firefox和safari目前支持baseline. 44 | B帧的存在虽然可以降低一些带宽占用确会引入更多的延迟, 不推荐使用. 经过测试H264的编码参数选择可以选择为baseline level3. 45 | 46 | 4, PPS和SPS 47 | 48 | 在RTMP场景中通常我们只会在推流开始的时候加入PPS和SPS, 但WebRTC要求在每个关键帧前面都有PPS和SPS, 这个问题我们可以在推流的时候解决, 也可以在把RTMP转成RTP的时候加入. 万能的ffmpeg已经支持这个bitstream filter -- dump_extra, 谢谢ffmpeg让音视频开发者节省了那么多的时间. 49 | 50 | 51 | 5, 音频转码 52 | 53 | RTMP的协议规范中音频支持pcma和pcmu, WebRTC也支持pcma和pcmu, 如果RTMP推流端推送的音视是pcma或者pcmu格式, 我们就不用转码了. 当然现实比较残酷, 在RTMP体系中大多数厂商和开源项目只支持AAC, 这个时候我们需要对音频做转码. 这样的工作对于万能的ffmpeg来说也只有一二十行代码的事情, 再一次谢谢ffmpeg让音视频开发者节省了那么多的时间.(看到ffmpeg的强大了吧, 如果想学ffmpeg 请购买大师兄的书<>) 54 | 55 | 6, 视频转封装 56 | 57 | 视频部分我们上边提到尽可能的用H264 baseline, 这样的话WebRTC支持也会比较好. 我们只需要把RTMP流转封装为RTP的流, 喂给相应的WebRTC mediaserver. 58 | 这部分可以借助FFmpeg来完成. 59 | 60 | 61 | **** 如何落地 62 | 63 | 目前身边完全没有完全匹配的需求, 这个方案目前并没有落地, 设想中的落地方式是, RTMP部分还是用现有的CDN, 自己部署WebRTC的边缘节点, 根据访问请求向CDN拉流. 64 | 需要开发的地方只有边缘节点WebRTC media server部分, 这部分我们可以借助一些开源的media server然后再做一些业务上的开发. 支持rtp输入的开源WebRTC mediaserver 有janus-gateway, medooze mediaserver. 65 | 66 | 67 | Talk is cheap, show me the code. 我实现了一个RTMP推流WebRTC播放的原型实现, 在阿里云上测试延迟在500ms以内. 68 | 完整的代码在这里 https://github.com/RTCEngine/rtmp-to-webrtc 69 | 70 | 71 | **** 最后 72 | 73 | 最后的最后, 当然是广告环节. 74 | 我已经加入学而思网校, 负责互动直播产品的研发. 75 | 目前音视频方向都还有很多坑, 客户端和服务端都比较缺人, 如果对音视频和WebRTC以及在线教育感兴趣欢迎联系我. 76 | 77 | 简历请砸向我: leeoxiang@gmail.com 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dotEngine 6 | 8 | 9 | 10 | 107 | 108 | 109 |

RTMP to WebRTC

110 | 111 |
112 |
113 |
114 | 117 | 118 |
119 | Video: 120 |
121 |
122 | 123 |
124 |
125 | 126 | 127 | -------------------------------------------------------------------------------- /mediaserver.js: -------------------------------------------------------------------------------- 1 | 2 | const getPort = require('get-port'); 3 | const medoozeMediaServer = require('medooze-media-server'); 4 | const format = require('string-format'); 5 | const execa = require('execa'); 6 | const SemanticSDP = require('semantic-sdp'); 7 | const SDPInfo = SemanticSDP.SDPInfo; 8 | const MediaInfo = SemanticSDP.MediaInfo; 9 | const CandidateInfo = SemanticSDP.CandidateInfo; 10 | const DTLSInfo = SemanticSDP.DTLSInfo; 11 | const ICEInfo = SemanticSDP.ICEInfo; 12 | const StreamInfo = SemanticSDP.StreamInfo; 13 | const TrackInfo = SemanticSDP.TrackInfo; 14 | const Direction = SemanticSDP.Direction; 15 | const CodecInfo = SemanticSDP.CodecInfo; 16 | 17 | const videoPt = 96; 18 | const audioPt = 100; 19 | const videoCodec = 'h264'; 20 | const audioCodec = 'opus'; 21 | 22 | let videoPort = null; 23 | let audioPort = null; 24 | 25 | 26 | //const RTMP_TO_RTP = 'ffmpeg -fflags nobuffer -i rtmp://ali.wangxiao.eaydu.com/live_bak/x_100_rtc_test -vcodec copy -an -bsf:v h264_mp4toannexb -f rtp -payload_type {pt} rtp://127.0.0.1:{port}' 27 | 28 | 29 | const RTMP_TO_RTP = "gst-launch-1.0 -v rtmpsrc location=rtmp://ali.wangxiao.eaydu.com/live_bak/x_100_rtc_test ! flvdemux ! h264parse ! rtph264pay config-interval=-1 pt={pt} ! udpsink host=127.0.0.1 port={port}" 30 | 31 | 32 | class MediaServer 33 | { 34 | constructor(publicIp) 35 | { 36 | this.endpoint = medoozeMediaServer.createEndpoint(publicIp); 37 | medoozeMediaServer.enableDebug(true); 38 | medoozeMediaServer.enableUltraDebug(true); 39 | 40 | this.streams = new Map(); 41 | } 42 | 43 | getStream(streamName) 44 | { 45 | return this.streams.get(streamName) 46 | } 47 | 48 | removeStream(streamName) 49 | { 50 | 51 | stream = this.streams.get(streamName) 52 | 53 | if (stream) { 54 | 55 | if (stream.videoStreamer) { 56 | stream.videoStreamer.stop() 57 | } 58 | 59 | if (stream.audioStreamer) { 60 | stream.audioStreamer.stop() 61 | } 62 | } 63 | 64 | this.streams.delete(streamName) 65 | 66 | } 67 | 68 | async createStream(streamName,rtmpUrl) 69 | { 70 | 71 | const videoStreamer = medoozeMediaServer.createStreamer(); 72 | const audioStreamer = medoozeMediaServer.createStreamer(); 73 | 74 | const video = new MediaInfo(streamName+':video','video'); 75 | const audio = new MediaInfo(streamName+':audio','audio'); 76 | 77 | //Add h264 codec 78 | video.addCodec(new CodecInfo(videoCodec,videoPt)); 79 | audio.addCodec(new CodecInfo(audioCodec,audioPt)); 80 | 81 | 82 | if (!videoPort) { 83 | videoPort = await this.getMediaPort(); 84 | audioPort = await this.getMediaPort(); 85 | } 86 | 87 | 88 | const videoSession = videoStreamer.createSession(video, { 89 | local : { 90 | port: videoPort 91 | } 92 | }); 93 | 94 | const audioSession = audioStreamer.createSession(audio, { 95 | local : { 96 | port: audioPort 97 | } 98 | }); 99 | 100 | this.streams.set(streamName, { 101 | videoPort: videoPort, 102 | audioPort: audioPort, 103 | videoStreamer: videoStreamer, 104 | audioStreamer: audioStreamer, 105 | video:videoSession, 106 | audio:audioSession 107 | }); 108 | 109 | 110 | let rtmp_to_rtp = format(RTMP_TO_RTP, {stream:streamName, pt: videoPt, port: videoPort}); 111 | 112 | console.log('rtmp_to_rtp ', rtmp_to_rtp); 113 | 114 | 115 | const gst = execa.shell(rtmp_to_rtp); 116 | 117 | gst.on('close', (code, signal) => { 118 | 119 | console.log('gst close', code, signal) 120 | }) 121 | 122 | gst.on('exit', (code, signal) => { 123 | 124 | console.log(code, signal) 125 | }) 126 | 127 | videoPort = null; 128 | audioPort = null; 129 | 130 | } 131 | async getMediaPort() 132 | { 133 | let port; 134 | while(true) 135 | { 136 | port = await getPort(); 137 | if(port%2 == 0){ 138 | break; 139 | } 140 | } 141 | return port; 142 | } 143 | async offerStream(streamName, offerStr) 144 | { 145 | let offer = SDPInfo.process(offerStr); 146 | 147 | const transport = this.endpoint.createTransport({ 148 | dtls : offer.getDTLS(), 149 | ice : offer.getICE() 150 | }); 151 | 152 | transport.setRemoteProperties({ 153 | audio : offer.getMedia('audio'), 154 | video : offer.getMedia('video') 155 | }); 156 | 157 | //Get local DTLS and ICE info 158 | const dtls = transport.getLocalDTLSInfo(); 159 | const ice = transport.getLocalICEInfo(); 160 | 161 | //Get local candidates 162 | const candidates = this.endpoint.getLocalCandidates(); 163 | 164 | let answer = new SDPInfo(); 165 | 166 | answer.setDTLS(dtls); 167 | answer.setICE(ice); 168 | 169 | for (let i=0;i { 208 | 209 | transport.stop() 210 | }) 211 | // now we only attach video 212 | outgoingStream.getVideoTracks()[0].attachTo(videoSession.getIncomingStreamTrack()); 213 | 214 | const info = outgoingStream.getStreamInfo(); 215 | 216 | answer.addStream(info); 217 | 218 | return answer.toString(); 219 | } 220 | } 221 | 222 | module.exports = MediaServer; 223 | 224 | 225 | --------------------------------------------------------------------------------