RTSP流Web播放:轉(zhuǎn)碼推流到HTTP-FLV的完整實踐)
前陣子接了個監(jiān)控平臺的項目需求本身一句話就能講完管理后臺里要能點開攝像頭實時看到車間畫面。攝像頭是現(xiàn)成的??怠⒋笕ARTSP 地址也能從設(shè)備管理頁面拿到可真正卡住的地方在于——Web 前端怎么把 RTSP 流播放出來。這個問題我估摸做過安防、物聯(lián)網(wǎng)、線上巡檢的同學都遇到過。瀏覽器不認 RTSP 協(xié)議直接把rtsp://admin:xxx192.168.1.64:554/...甩給video標簽肯定不通。大多數(shù)人第一反應是“用前端插件”或者“讓瀏覽器裝客戶端”但正經(jīng)做項目前端面對的是一堆不能裝插件的員工電腦和手機瀏覽器只能在服務端想辦法。這篇我把 Java 側(cè)接收 RTSP 流、解碼轉(zhuǎn)碼、再推給前端實時播放的完整鏈路拆開講一遍包含我從選型、編碼、聯(lián)調(diào)到排坑的實操記錄適合正在做類似功能的后端、全棧和流媒體入門開發(fā)者參考。1. 從 RTSP 到 Web 播放中間到底少了哪幾環(huán)先別急著寫 Java 代碼得先搞清楚為什么瀏覽器不能直接播 RTSP。RTSPReal Time Streaming Protocol本身是一個會話控制協(xié)議負責發(fā)起、暫停、結(jié)束流媒體會話實際音視頻數(shù)據(jù)靠 RTP 包傳輸另外還有 RTCP 做質(zhì)量控制。這一套組合拳在網(wǎng)絡(luò)攝像頭領(lǐng)域非常成熟但它和 Web 生態(tài)完全是兩個世界。打個比方RTSP 像是餐廳里的“點菜流程現(xiàn)炒現(xiàn)送”服務員問你吃什么、后廚炒完直接端到你面前。這個過程靈活但需要維持一整條獨立的溝通通道。瀏覽器里的video標簽則更像是“便利店自取”只想通過 HTTP 地址從貨架上拿標好格式的商品根本不關(guān)心你后廚怎么運作。所以要讓攝像頭畫面進 Web 頁面服務端必須充當一個“外賣中轉(zhuǎn)站”把 RTSP 的會話控制、RTP 傳輸轉(zhuǎn)換為瀏覽器認識的 HTTP 流。除了協(xié)議差異還有編碼層面的問題。絕大多數(shù)網(wǎng)絡(luò)攝像頭輸出 H.264這個瀏覽器能解但很多新款設(shè)備默認或可選輸出 H.265HEVC問題就來了Chrome 長期以來不原生支持 H.265 硬解只在部分系統(tǒng)和硬件環(huán)境下能軟解或通過系統(tǒng)解碼器間接播放Safari 對 H.265 的支持也不統(tǒng)一。也就是說哪怕協(xié)議打通了編碼不合適依然白搭。這里有兩種處理思路一種是不動編碼只把 H.264 裸流重新封裝成適合 Web 播放的容器格式這叫“轉(zhuǎn)封裝”transmux另一種是正兒八經(jīng)解碼后再重新編碼成 H.264這叫“轉(zhuǎn)碼”transcode。轉(zhuǎn)封裝性能開銷低但要求源流本來就是 H.264轉(zhuǎn)碼靈活任何 H.265 甚至 H.264 都能統(tǒng)一輸出成 H.264代價是吃 CPU/GPU。題目標題里帶著“解碼”兩個字我理解就是包含了轉(zhuǎn)碼這個動作后面我也著重講這條路。再有一個維度是延遲。監(jiān)控場景講究實時你對著畫面喊話或者盯著設(shè)備動作延遲 3 秒還能忍延遲 10 秒基本沒法用。常見的分發(fā)協(xié)議里HLS兼容性最好大部分手機瀏覽器直接支持Safari 也能播但切片機制決定了延遲普遍在 3-15 秒HTTP-FLV延遲可以控制在 1-3 秒PC 端有 flv.js 這類 MSE 封裝庫支持移動端兼容性差一些WebRTC延遲最低能到幾百毫秒但服務端接入復雜度高需要另外做信令服務和媒體協(xié)商。所以“先選傳輸再寫代碼”是最重要的。你要是沒想清楚前端到底跑在 PC 還是手機、延遲要求多高、并發(fā)幾路上來就寫 JavaCV 拉流后面很可能推倒重來。2. Java 拉流轉(zhuǎn)推的技術(shù)選型別急著寫代碼我見過不少人一拿到需求就直奔代碼用 JavaCV 把 RTSP 流接進來然后塞進 WebSocket 里往瀏覽器推。這個方案在單路、內(nèi)網(wǎng)、實驗性項目里能跑通但稍微上點規(guī)模就麻煩。選型階段多花半小時后面能省幾天。先把 Java 生態(tài)里常見的四條路線攤開看方案實現(xiàn)要點延遲優(yōu)點缺點JavaCV 流媒體服務器JavaCV 拉流轉(zhuǎn)碼推 RTMP/RTSP 給 SRS 或 MediaMTX前端從服務器取 HTTP-FLV / HLS / WebRTC1-3 秒分層清晰穩(wěn)定支持并發(fā)擴展多部署一個流媒體服務JavaCV 自建 HTTP-FLV 輸出自己用 Netty/Tomcat 寫一個簡易分發(fā)服務1-3 秒減少外部依賴要處理大量連接細節(jié)運維和排錯成本高純 FFmpeg 命令行進程Java 里ProcessBuilder調(diào) ffmpeg1-3 秒靈活不用寫 Java 編解碼進程管理麻煩回傳狀態(tài)、停止重啟都不優(yōu)雅只上流媒體服務器SRS/MediaMTX 直接拉攝像頭 RTSP 并分發(fā)1-3 秒部署最簡單脫離了 Java 業(yè)務邏輯權(quán)限控制、動態(tài)管理不方便我自己最終選了第一種也就是 JavaCV 負責“接入轉(zhuǎn)碼”SRS或者輕量一點的 MediaMTX負責“分發(fā)”。理由很現(xiàn)實Java 端的強項是業(yè)務集成、攝像頭上線下線管理、權(quán)限控制、和現(xiàn)有后臺系統(tǒng)對接而真正的流分發(fā)比如 HTTP-FLV 的 chunked 響應、WebRTC 的 ICE/DTLS 協(xié)商、HLS 切片緩存SRS 這類成熟服務器已經(jīng)處理得非常好沒有必要自己在 Java 里重造輪子。那么問題來了什么時候才需要 Java 參與如果只是臨時看一路畫面你甚至可以不用寫代碼直接命令行跑/usr/local/srs/etc/...那樣的配置就能拉流。但如果你的系統(tǒng)里攝像頭有幾十路、幾百路需要動態(tài)從數(shù)據(jù)庫讀取攝像頭列表、按用戶權(quán)限點播、隨時控制開啟和關(guān)閉這時候 Java 服務就是必不可少的“管家”。它負責按照業(yè)務規(guī)則拉起和停止一路一路的轉(zhuǎn)推任務而分發(fā)這件事繼續(xù)交給專業(yè)流服務器。還有一點我覺得值得提醒JavaCV 的依賴體積很大javacv-platform會帶入 OpenCV、FFmpeg、OpenBLAS 等一堆平臺動態(tài)庫。如果你的服務器在內(nèi)網(wǎng)Maven 中央倉庫不一定能順利拉這些大包建議只引入真正需要的模塊dependency groupIdorg.bytedeco/groupId artifactIdjavacv/artifactId version1.5.9/version /dependency dependency groupIdorg.bytedeco/groupId artifactIdffmpeg-platform/artifactId version6.1.1-1.5.9/version /dependency這樣只帶 FFmpeg不拖 OpenCV 那些無關(guān)包部署包能小一大截。版本之間要注意對應關(guān)系javacv和ffmpeg-platform的版本號后綴經(jīng)常一致比如1.5.9對應6.1.1-1.5.9。3. JavaCV 上場拉流、解碼、再推流的完整鏈路JavaCV 本質(zhì)上是 JavaCPP 對 FFmpeg 的封裝所以你幾乎可以把 FFmpeg 的命令行參數(shù)思維平移到代碼里。下面是我在一路攝像頭從“拉取”到“推送”過程中會涉及的核心代碼。3.1 拉流端配置參數(shù)比代碼更重要初始化一個FFmpegFrameGrabber看似簡單真正決定穩(wěn)不穩(wěn)定的是幾個隱藏參數(shù)。FFmpegFrameGrabber grabber new FFmpegFrameGrabber(rtspUrl); // 用 TCP 而不是 UDP 拉流。UDP 在內(nèi)網(wǎng)偶爾丟包畫面會花屏TCP 犧牲一點延遲換來穩(wěn)定。 grabber.setOption(rtsp_transport, tcp); // 設(shè)置套接字超時單位是微秒這里設(shè) 5 秒。攝像頭斷網(wǎng)后拉流線程不至于一直掛死。 grabber.setOption(stimeout, 5000000); // 減少緩沖區(qū)對實時性有幫助。 grabber.setOption(fflags, nobuffer); // 遇到損壞的幀嘗試跳過而不是直接中斷。 grabber.setOption(err_detect, ignore_err); grabber.start();stimeout這個參數(shù)我強烈建議設(shè)置。攝像頭或者交換機電一斷如果沒有超時grabber.start()或grabber.grab()會阻塞很久你的“重連機制”就沒法及時觸發(fā)。別問我怎么知道連續(xù)燒掉幾個重連線程之后我就把它列成默認配置了。3.2 轉(zhuǎn)碼與推流配置盡量向低延遲編碼參數(shù)靠攏接下來是FFmpegFrameRecorder。這一步的目標是把攝像頭原始流解碼后重新編碼成 H.264/AAC封裝成 FLV然后推給上游流媒體服務器。FFmpegFrameRecorder recorder new FFmpegFrameRecorder(pushUrl, width, height, 1); recorder.setFormat(flv); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setVideoOption(preset, veryfast); recorder.setVideoOption(tune, zerolatency); recorder.setVideoBitrate(2000 * 1000); recorder.setFrameRate(frameRate); recorder.setGopSize((int) frameRate * 2); if (hasAudio) { recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setAudioBitrate(64 * 1000); recorder.setSampleRate(44100); recorder.setAudioChannels(1); } recorder.start();這里幾個選項要解釋一下presetveryfast犧牲一點壓縮率換取更快的編碼速度對實時轉(zhuǎn)碼來說比文件體積重要得多tunezerolatency要求編碼器不引入額外延遲會減少 B 幀的使用對低延遲很關(guān)鍵setGopSizeGOP 是關(guān)鍵幀間隔。如果設(shè)得太小比如 1 秒一個關(guān)鍵幀帶寬占用高設(shè)得太大比如 100 幀以上前端起播時可能要等一個關(guān)鍵幀黑屏時間會變長。我一般取幀率×2也就是 2 秒一個關(guān)鍵幀平衡起播速度和帶寬。pushUrl如果是推給 SRS/MediaMTX一般長這樣rtmp://127.0.0.1:1935/live/cam_001SRS 默認監(jiān)聽 RTMP隨后對外提供 HTTP-FLV地址就是http://127.0.0.1:8080/live/cam_001.flv這塊前置服務怎么搭不是 Java 代碼的事我會放到后面第 5 章結(jié)合部署經(jīng)驗一起說。3.3 主循環(huán)別把“拉取”和“錄制”寫死成一步核心的循環(huán)其實很短Frame frame; long lastTime System.currentTimeMillis(); while (running) { try { frame grabber.grab(); // 同時拉取視頻幀和音頻幀 if (frame ! null) { recorder.record(frame); } } catch (Exception e) { log.error(拉流/推流異常: {}, e.getMessage()); break; // 跳出循環(huán)交給上層重連邏輯 } }這里有個容易踩的坑grab()會同時返回視頻幀和音頻幀而grabImage()只返回視頻幀。如果你的攝像頭帶音頻又不想保存聲音那就別用帶音軌的參數(shù)初始化recorder否則會出現(xiàn)“av_interleaved_write_frame(): Invalid data”之類的報錯。最穩(wěn)妥的做法是先用ffprobe看看攝像頭實際有沒有音頻軌道沒有音頻的設(shè)備就老老實實讓recorder保持純視頻模式。grab()返回的Frame本質(zhì)上是解碼后的原始數(shù)據(jù)所以recorder.record(frame)再編碼這就是完整的“解碼轉(zhuǎn)碼”過程。如果源流本身就是 H.264 且你不想折騰硬件編碼也可以走更低層的 AVPacket 接口做轉(zhuǎn)封裝但 JavaCV 里這套 API 比較繞一般項目沒必要。3.4 不要重復創(chuàng)建 grabber一個攝像頭一個任務線程真實項目中你會面對很多路攝像頭。我見過同學寫“開啟視頻”的接口每次請求都new FFmpegFrameGrabber().start()然后也不存引用過一會兒連接數(shù)爆炸。正確做法是給每路攝像頭維護一個獨立的“拉流轉(zhuǎn)推任務”任務內(nèi)部管理 grabber 和 recorder 的生命周期并提供停止、重啟兩個方法。我這里給一個簡單的任務骨架public class RtspPushTask implements Runnable { private final String rtspUrl; private final String pushUrl; private volatile boolean running true; public void stop() { running false; } Override public void run() { while (running) { FFmpegFrameGrabber grabber null; FFmpegFrameRecorder recorder null; try { grabber createGrabber(); grabber.start(); recorder createRecorder(pushUrl, grabber); recorder.start(); log.info(轉(zhuǎn)推已啟動: {} - {}, rtspUrl, pushUrl); Frame frame; while (running (frame grabber.grab()) ! null) { recorder.record(frame); } } catch (Exception e) { log.warn(轉(zhuǎn)推中斷: {}, e.getMessage()); } finally { closeQuietly(recorder); closeQuietly(grabber); } if (running !Thread.currentThread().isInterrupted()) { sleepSeconds(3); // 重連等待 } } } }這樣斷線之后任務會自動重連不會讓一個異常就把整個服務拖垮。真正的業(yè)務層只需要維護一個MapString, RtspPushTask用戶點播就start()關(guān)閉就stop()。3.5 如果想脫離 SRS純 Java 輸出 HTTP-FLV 的思路有朋友會問我不想額外部署流媒體服務器Java 能不能直接把 FLV 推給前端能但要自己處理很多分發(fā)邊界。思路是FFmpegFrameRecorder支持把 FLV 數(shù)據(jù)寫入一個自定義OutputStream你的 HTTP 服務器Netty/Tomcat NIO把這個 OutputStream 接到請求通道上flv.js 請求 URL 時你先把 FLV 文件頭寫出去然后不斷把編碼后的 FLV tag 寫進響應體保持連接不關(guān)。聽起來不算復雜實際做起來要命的是連接管理瀏覽器刷新要清理舊連接網(wǎng)速慢要背壓并發(fā)一多要控制內(nèi)存。我第一版為了圖省事這么搞過一次后來發(fā)現(xiàn)“能播”和“能穩(wěn)定播”之間差了十萬八千里。如果你不是特別明確知道自己在做什么還是讓 SRS/MediaMTX 去干這事Java 這邊做好拉流和轉(zhuǎn)碼就夠了。4. 前端播放接入flv.js 為主、HLS 兜底的落地姿勢后端推流鏈路通了前端反而簡單但也有一些細節(jié)直接影響用戶體驗。4.1 flv.js 播放 HTTP-FLV先裝包npm install flv.js然后初始化播放器。這里面的參數(shù)優(yōu)化我實際調(diào)過很多遍import flvjs from flv.js; if (flvjs.isSupported()) { const videoElement document.getElementById(video); const player flvjs.createPlayer({ type: flv, isLive: true, url: http://your-server:8080/live/cam_001.flv, }, { enableStashBuffer: false, // 關(guān)掉緩存顯著降低延遲 isLive: true, lazyLoad: false, autoCleanupSourceBuffer: true, }); player.attachMediaElement(videoElement); player.load(); player.play().catch(console.error); }enableStashBuffer: false是低延遲的關(guān)鍵。默認情況下 flv.js 會緩沖一部分數(shù)據(jù)網(wǎng)絡(luò)波動時不那么容易卡但實時性會變差。如果是監(jiān)控類應用我會選擇關(guān)閉讓畫面盡量貼近實時如果是直播帶貨那種要極穩(wěn)的反而建議保留緩沖。在 Vue 3 里接入時要注意播放器實例的生命周期script setup import flvjs from flv.js; let player null; function playStream(url) { if (player) { player.destroy(); } if (!flvjs.isSupported()) { alert(當前瀏覽器不支持 flv.js); return; } player flvjs.createPlayer({ type: flv, isLive: true, url }, { enableStashBuffer: false }); player.attachMediaElement(videoRef.value); player.load(); player.play(); } onBeforeUnmount(() { player player.destroy(); }); /script4.2 移動端 H5 的兜底HLSflv.js 的兼容性在 Chrome、Edge、Firefox 上都很穩(wěn)唯獨 iOS Safari 和部分 Android 廠商瀏覽器沒法用 MSE 處理 HTTP-FLV。遇到跨平臺需求我常規(guī)做法是后端同時讓 SRS 開啟 HLS 輸出前端用 hls.js 或者原生 HLS 播放。HLS 在 SRS 里幾乎不需要額外配置生成的是這樣的地址http://your-server:8080/live/cam_001.m3u8前端判斷邏輯可以簡單一點PC 上用 flv.js移動端優(yōu)先走原生video標簽的 HLS 播放能力。Safari 直接支持 HLSAndroid 上用 hls.js 兜底。function createPlayer(url) { const isIOS /iPhone|iPad|Mac/.test(navigator.platform) || (navigator.userAgent.includes(Mac) ontouchend in document); if (isIOS) { const video document.getElementById(video); video.src url.replace(.flv, .m3u8); video.play(); return; } // 否則 flv.js }4.3 必須說的 WebRTC 選項如果你的項目對延遲要求更高比如遠程控制、在線對講HTTP-FLV 1-3 秒仍然不夠那就要考慮 WebRTC。SRS 4.0 以上已經(jīng)支持 WebRTC 分發(fā)可以直接拉 RTSP 轉(zhuǎn) WebRTC前端通過RTCPeerConnection拿流。但 WebRTC 的坑在于信令服務和客戶端的 ICE 配置不是一段代碼能跑起來的這里不展開。我只建議把 WebRTC 當作延遲敏感型功能的進階方案能不用就不用畢竟項目交付的穩(wěn)定性優(yōu)先級大于極致延遲。5. 真實項目里更值錢的部分兼容性、重連與性能調(diào)優(yōu)代碼能跑起來只是起點。下面這幾類問題是我在每個攝像頭流項目里幾乎都會遇到的提前寫在這能少走點彎路。5.1 先驗證 RTSP 地址再用 ffprobe 摸清設(shè)備底細很多同事拿到的“RTSP 地址”是廠商或者施工人員隨手給的格式五花八門。??党R姷母袷绞莚tsp://admin:password192.168.1.64:554/Streaming/Channels/101大華類似的格式是rtsp://admin:password192.168.1.64:554/cam/realmonitor?channel1subtype0其中/Streaming/Channels/101里的101通常是主碼流102是子碼流。大華subtype0也是主碼流subtype1是子碼流。地址不對后面所有操作都是白費所以第一步建議先在服務器上用 ffprobe 驗證ffprobe -rtsp_transport tcp -i rtsp://admin:password192.168.1.64:554/Streaming/Channels/101ffprobe 會打印出流的編碼、分辨率、音頻參數(shù)。知道了這些你才知道該讓 JavaCV 按什么寬度高度去初始化 recorder。另外注意密碼里如果包含、:、/這些特殊字符一定要做 URL 編碼否則 RTSP 解析器會斷錯位置導致認證失敗。5.2 主碼流還是子碼流性能與畫質(zhì)的平衡攝像頭通常有兩路碼流主碼流分辨率高、碼率高適合事后查證子碼流分辨率低、碼率低適合實時預覽。做實時播放時如果設(shè)備數(shù)量不多、服務器 CPU 充足可以直接取主碼流如果一路 4K 都要在服務端解碼再轉(zhuǎn) H.264CPU 壓力會很大我遇到的實際場景里4K 轉(zhuǎn)碼單路就要吃掉不少 CPU 核。處理辦法很簡單實時預覽默認用子碼流關(guān)鍵畫面“回放原始錄像”時再切主碼流。這在業(yè)務設(shè)計上要前置而不是等性能報警了再改。編碼格式上盡量在攝像頭后臺把編碼格式設(shè)為 H.264。如果設(shè)備強制輸出 H.265而前端又要 H.264JavaCV 轉(zhuǎn)碼是兜得住但那就是純 CPU 轉(zhuǎn)碼成本很高。有 NVIDIA 顯卡的服務器可以試試把編碼器切到硬件recorder.setVideoCodecName(h264_nvenc); recorder.setVideoOption(preset, p1);注意setVideoCodecName和setVideoCodec是兩種設(shè)置方式不要混用。硬件編碼能大幅降低 CPU 占用但部署機器必須帶對應顯卡云服務器一般要選 GPU 實例成本自己權(quán)衡。5.3 斷線重連核心循環(huán)的異常處理攝像頭設(shè)備不像應用服務器那么穩(wěn)定斷電、重啟、網(wǎng)絡(luò)波動都很常見。如果轉(zhuǎn)推任務斷開后不重連前端畫面就是一把黑屏用戶只能刷新。前面給的任務骨架里有一個while (running)外層循環(huán)配合Thread.sleep(3000)就是斷線重連的基本實現(xiàn)。另外要小心一種情況攝像頭重啟后分辨率、幀率可能發(fā)生變化舊recorder還在用原來的寬高FFmpeg 會報Width or height change之類的錯誤。遇到這種情況常規(guī)做法是捕獲異常后把舊的 recorder 關(guān)掉重新從 grabber 讀取新的寬高再創(chuàng)建 recorder。我在代碼里推薦的做法是每次異常退回到外層重連邏輯而不是在循環(huán)里嘗試“修復”這樣狀態(tài)管理最簡單。5.4 首屏黑屏和延遲調(diào)優(yōu)一個容易被忽略的源頭首屏打開慢經(jīng)常不是網(wǎng)絡(luò)問題而是 GOP 問題。攝像頭端的關(guān)鍵幀間隔GOP如果設(shè)置是 4 秒那新播放器接入時必須等到下一個關(guān)鍵幀才開始有畫面平均黑屏 2 秒。這個問題有兩個解法調(diào)小攝像頭的 I 幀間隔比如 2 秒一次在流媒體服務器上開啟 GOP 緩存。SRS 里 HTTP-FLV 域默認會緩存最近一個 GOP新用戶接入時直接用緩存的關(guān)鍵幀起播黑屏時間能降到幾百毫秒。代價是多占一點內(nèi)存但監(jiān)控場景這點開銷非常值。延遲問題則要前后端配合。前端關(guān)掉 flv.js 的 stash 緩沖后端編碼時用zerolatency參數(shù)如果中間還經(jīng)過了 SRSSRS 的mrmerged-reduce算法默認開啟一般不需要額外動。我在多路攝像頭、全鏈路 HTTP-FLV、局域網(wǎng)環(huán)境下實測延遲普遍能穩(wěn)定在 1 秒以內(nèi)公網(wǎng)環(huán)境會到 1-2 秒已經(jīng)足夠大多數(shù)業(yè)務使用。5.5 部署形態(tài)Java 和流媒體服務器怎么擺都行但邊界要清晰最后一句話給個實在建議別把 Java 服務里塞滿流媒體分發(fā)邏輯。我的部署習慣是一臺服務器上同時跑 Java 服務和 SRSDocker 起Java 只負責拉流、轉(zhuǎn)碼、推 RTMP 給 SRSSRS 對外提供 HTTP-FLV/HLS。這樣排查問題時有非常清晰的邊界前端放不出畫面先試 SRS 的 HTTP-FLV 地址能不能直接播放能播說明問題在前端不能播就看 SRS 日志再不行看 Java 日志五秒鐘就能定位問題出在哪一段。我自己也是踩過幾次坑才堅定這個分層思路的。第一次做類似系統(tǒng)圖省事把 HTTP-FLV 直接寫在 Java 里結(jié)果同時在線十幾路時頻繁出現(xiàn)連接掛起和內(nèi)存上漲天天被運維催著看堆棧。后來換成 SRS 做分發(fā)Java 端代碼反而更簡單了穩(wěn)定性還明顯提升。所以說選型階段不用迷信“All in Java”該交給專業(yè)組件的就交出去Java 把業(yè)務邏輯這塊做好做扎實這個項目基本就成了一半。