使用 multipart/x-mixed-replace 實現 http 實時視頻流

關於實時視頻傳輸,業界已經有很是多成熟方案,分別應用在不一樣需求場景。本文介紹一種基於 HTTP ,很是簡單、易理解的方案,實用性不強,但有助於理解 HTTP 協議。javascript

從攝像頭讀取視頻幀

node 的硬件操做能力偏弱,運行時自己並無提供太多硬件接口,因此要調用硬件設備須要找到合適的庫。html

有許多工具能夠實現從攝像頭讀取視頻流,簡單起見,咱們選用了比較通用的框架: OpenCV,這是一個 c++ 寫的計算機視覺處理工具,包含了各種圖像、視頻處理功能,對應的 node 版本:node-opencv,安裝過程比較繁瑣,在 windows 下容易出錯,建議參考官網提供的教程(熟悉 docker 的同窗,可使用 node-opencv 鏡像)。前端

視頻是一個很是複雜的概念,簡單起見,本例中僅經過取幀的方式實現,也就是間隔一段時間從攝像頭讀取當前圖像,連續多張圖就構成了一個視頻。實現這一點,能夠經過調用 OpenCV 的 VideoCapture 類獲取幀,代碼:java

import { promisfy } from "promisfy";
import cv from "opencv";

const video = new cv.VideoCapture(0);
const read = promisfy(video.read, video);
setInterval(() => {
  const frame = await read();
  console.log(frame.length);
}, 100);
複製代碼

代碼很簡單,新建 VideoCapture 實例後,調用 read 接口讀取當前幀。node

使用 multipart 實現響應流

有了視頻幀以後,接下來的問題就是如何傳輸到客戶端,這裏有不少成熟的傳輸技術,包括: HLSRTSPRTMP等。這些技術有必定的複雜性,各自有其適用場景,若是業務場景對實時性、性能沒有過高要求,那顯得有點牛刀殺雞了。有一個更簡單,對前端更友好的方案: http 的 multipart 類型。c++

multipart 經過 content-type 頭定義。這裏稍微解釋一下,content-type 用於聲明資源的媒體類型,瀏覽器會根據媒體類型的值作出不一樣動做。好比,一般來講,chrome 遇到application/zip會下載資源;遇到application/pdf會啓動預覽,正是經過判斷這個頭部作出的分支選擇。git

而 multipart 類型值聲明服務器會將 多份數據 合併成當個請求。比較常見的例子是 form 表單提交,瀏覽器默認的 form 表單提交行爲就是經過指定 content-type: multipart/form-data; boundary=xxx 頭,服務器接收到後會根據 boundary 分割內容,提取多個字段。規範文檔 rfc1341 指定了四種子類型:multipart/mixedmultipart/alternativemultipart/digestmultipart/parallel,主流瀏覽器則擴展了一種新的類型: multipart/x-mixed-replace(不過因爲不多用到這個特性,並且實現上容易出安全問題,MDN 已經標誌爲過時特性),該類型聲明 body 內容由多份 XML 文檔按序組合組合而成,每次到來的文檔都會被用於建立新的 dom 文檔對象,並觸發文檔對象 onload 事件。下面,咱們嘗試構建一個最簡單的 multipart 響應流:github

import { Readable } from "stream";
import http from "http";

const boundary = "gc0p4Jq0M2Yt08jU534c0p";

// 必須使用流的方式實現
// 不然res會提早關閉
// 可讀流的實現,請參考:https://nodejs.org/api/stream.html#stream_implementing_a_readable_stream
class MockStream extends Readable {
  constructor(...arg) {
    super(...arg);
    this.count = 0;
  }
  async _read() {
    const buffer = Buffer.concat([
      new Buffer(`--${boundary}\r\n`),
      new Buffer("Content-Type: text/html\r\n\r\n"),
      new Buffer(`<html><body>${this.count}</body></html>\r\n\r\n`)
    ]);
    this.count++;
    setTimeout(() => {
      this.push(buffer);
    }, 1000);
  }
}

http
  .createServer((req, res) => {
    // 首先輸出響應頭
    res.writeHead(200, {
      "Content-Type": `multipart/x-mixed-replace; boundary="${boundary}"`
    });
    const stream = new MockStream();
    stream.pipe(res);
  })
  .listen(3000);
複製代碼

上例實現了在一次 response 中返回多份 html 文檔,返回結構大體以下:chrome

HTTP/1.0 200 OK
Content-Type: multipart/x-mixed-replace; boundary=gc0p4Jq0M2Yt08jU534c0p
X-Request-ID: bcd9f083-af7a-4419-94bd-0e47851a542d
Date: Tue, 12 Mar 2019 05:04:39 GMT

--gc0p4Jq0M2Yt08jU534c0p Content-Type: text/html <html><body>0</body></html> --gc0p4Jq0M2Yt08jU534c0p Content-Type: text/html <html><body>1</body></html> ... 複製代碼

與常見的 http 響應相比,上例有兩個特色。第一,在 header 中並無指明 content-length 頭,客戶端沒法預知資源大小,按規範,在這條 TCP 鏈接中斷以前所傳輸過來的數據都是本次響應的內容,這個特性能夠用於構建一個持久、可擴展的響應流,很是契合實時視頻傳輸場景。第二點是,response 的 body 部分由多份資源按序排列而成,並使用 boundary 字符串標誌資源的分割點,客戶端可使用 boundary 字符串抽取、解析出每一份資源的內容。好比在 nodejs 環境下:docker

import http from "http";
import fs from "fs";

const req = http.request("http://localhost:3000/api/video", res => {
  let cache = new Buffer(0);
  res.on("data", part => {
    cache = Buffer.concat([cache, part]);
    cache = retriveFrame(cache);
  });
});

req.end();

function retriveFrame(buff) {
  const boundary = new Buffer("gc0p4Jq0M2Yt08jU534c0p");
  const index = buff.indexOf(boundary);

  // 當前讀取到的buff還未到達分割點
  if (index === -1) {
    return buff;
  }

  // 以boundary爲界,分割出幀
  const frame = buff.slice(0, index);
  console.log(frame);

  // 保留解析出幀後,剩餘的部分
  // 供下次解析
  return buff.slice(index + boundary.length);
}
複製代碼

傳輸視頻流

有了視頻幀,有了傳輸手段,接下來咱們看看如何傳輸視頻流。首先,咱們須要構造服務端:

import cv from "opencv";
import { promisfy } from "promisfy";
import { Readable } from "stream";
import fs from "fs";
import http from "http";

const boundary = "gc0p4Jq0M2Yt08jU534c0p";

class VideoStream extends Readable {
  constructor(opt) {
    super(opt);
    this._vid = opt.vid;
  }
  async _read() {
    const vid = this._vid;
    const read = promisfy(vid.read, vid);
    const frame = await read();
    const buffer = Buffer.concat([
      new Buffer(`--${boundary}\r\n`),
      new Buffer("Content-Type: image/jpeg\r\n\r\n")
    ]);
    const result = Buffer.concat([buffer, frame.toBuffer()]);
    this.push(result);
  }
}

http
  .createServer((req, res) => {
    res.writeHead(200, {
      "Content-Type": `multipart/x-mixed-replace; boundary="${boundary}"`
    });
    const vid = new cv.VideoCapture(0);
    const stream = new VideoStream({ vid });
    stream.pipe(res);
  })
  .listen(4000);
複製代碼

代碼邏輯分兩部分,一是持續調用 OpenCV 接口,讀取攝像頭幀;二是經過 stream 形式,將圖片經過 http 協議輸出到客戶端。只要客戶端支持 multipart/x-mixed-replace 頭,就能夠從響應中讀取視頻幀,chrome、Firefox 在這一點上有比較好的支持,只要使用 <img /> 標籤就能夠實現視頻流效果:

<img src="http://localhost:4000/" />
複製代碼

上例代碼已經放在倉庫 node-case 上,歡迎取閱。

總結

本文提供了一個簡單的視頻直播方案,有兩個重點,一是在 node 環境下如何獲取攝像頭幀;二是若是經過一個簡單的 HTTP 響應傳輸視頻幀。勝在簡單、直觀,但存在許多問題:

  1. OpenCV 編解碼效率並不高,替代方案是 FFMPEG,本文未涉及
  2. multipart/x-mixed-replace 是單次 http 請求-響應模型,若是網絡中斷,會致使視頻流異常終止,必須從新鏈接
  3. 沒法同時輸出音頻

針對專業、高性能要求的場景,建議仍是使用專用協議,如 HLS、RTSP 等。

相關文章
相關標籤/搜索