RTSP(H264/H265) to MP4の超低遅延ブラウザ再生(配信)

はてブ数 2021/02/22プログラム::TCP/IP

カメラ等で使われるRTSPストリームはブラウザとすこぶる相性が悪く、以前はFlashを使って親戚のRTMPが再生されたりしていましたが、もはや過去のお話。

最近は、分割した MPEG4 ファイルを連続再生する HLS や MPEG-DASH が主流です。しかし、すべてを数秒に分割する仕組みのため通信効率が悪くなってしまいますし、どうやっても遅延の問題が避けられません。

かと言って WebRTC はP2P用に設計されていて、ブラウザ間ビデオチャットには向いていますが、配信等の目的に使うことは難しく、そもそもUDPが使えない環境では動作もしません。

そこで超低遅延(1秒未満)のストリーミング再生(配信)システムを構築しました。

更新履歴

  • 2022/09/05 WebCodecs正式版に対応。
  • 2021/04/29 H.265/HEVCに対応。
  • 2021/04/21 エラー内容をHTML上で表示するよう変更。
  • 2021/02/22 初版。

デモ

デモを設置しています

rtsp:// で始まるURLを入力すると、RTSPをリアルタイムで変換を確認できます。残念ながら適当なフリーのRTSPソースがないため、各自ご用意ください(192.168.x.xxのアドレスで試す人が多いようですが、当然接続できません。グローバルIPを持ったRTSPサーバを指定してお試しください)。

  • Chrome および Firefox が推奨です。
  • 対応しているのは「H.264/avc1」か「H.265/hvc1(HEVC)」ストリームのみです。
  • デモはセキュリティのため以下の制限を加えています。
    • RTSPパスワード認証は使用不可に設定しています。
    • RTSP接続ポートは 554 のみに制限されています。
    • 再生時間は60秒に制限されています。
  • 「WebSocket Server Down」と言われた場合は、変換サーバの起動忘れなので、コメント or ご連絡ください。
  • デモですので、常識の範囲内でご利用ください*1

*1 : 外部から利用する等はやめてください。ログは取っていますので発見次第対処します。

仕組み

rtsp_to_mp4.png

RTSPサーバに接続して、リアルタイムでmp4に変換しています。コンテナ(フォーマット)を変換していますが再圧縮しているわけではないので高速に動作します。ffmpegを使っても同様のことが可能ですが、ffmpegでは変換過程で数秒程度の遅延が発生します。

ffmpeg -rtsp_transport tcp -i rtsp://<ip_address>/path -c:v copy -an -movflags empty_moov+omit_tfhd_offset+frag_keyframe+default_base_moof -f mp4 pipe:1

mp4に変換されたデータを WebSocket 経由でブラウザに送信し、受け取ったデータを JavaScript で MediaSource に入力し、VIDEOタグで表示しています。

let sourceBuffer;
const ms  = new MediaSource();
ms.addEventListener('sourceopen', function(evt){
	sourceBuffer= ms.addSourceBuffer('video/mp4; codecs=avc1.64001E');
});

const VIDEO = document.getElementById('video');
VIDEO.src   = window.URL.createObjectURL(ms);

const ws = new WebSocket('ws://example.com/');
ws.binaryType = "arraybuffer";

ws.onmessage = function(evt) {
	sourceBuffer.appendBuffer( evt.data );
}

実際はもう少し複雑ですが、フロントエンド(JavaScript)では難しいことはしていません。デモページ内のソースに、スクリプトが全部書かれていますので、興味のある方はご覧ください。

変換サーバは?

今のところ非公開です。興味のある方はメール等でご連絡ください。ライセンス等する予定です。

まとめ

  • 映像が目的だったため、今のところオーディオには非対応です。
  • H264の圧縮も伸張もしていないので、H264がらみの特許問題は起きないはずです。
  • Windows Media ServerやReal Serverがそうだったように、将来的にはRTSPが廃れるか、ブラウザがRTSPやRTSPライクな何かをサポートして状況は変化するように思います。

参考文献

  • html5_rtsp_player
    • WebSocket経由でRTSPサーバにアクセスし、JavaScript側でRTSP通信をしながらmp4を再構成しています。原理的には似ていますが、サーバ/クライアントでの処理分担が異なります。サーバはNode.jsで書かれていますが、30行程度のスクリプトでサーバを自作することも可能でした。
  • v4l2rtspserver
    • V4L2を使用した、RTSPサーバ。H264で出力をするためには、カメラがH264エンコーダーを内蔵しているか、V4L2対応のH264ハードウェアエンコーダーが必要。
  • fmp4streamer
    • V4L2(H264)なソースから、ブラウザで再生可能なH264ストリームを生成するソフト。やってることは一緒。ただ作り込みが甘いのか、試した限りだとリアルタイム性がいまいち。多少書き換えれば改善するかもしれもい。

メモ

Video4Linuxとffmpegを使用して、カメラからリアルタイムH265を吐き出す方法。

ffmpeg -f video4linux2 -i /dev/video0 -s 640x360 -c libx265 -pix_fmt yuv420p -fflags nobuffer -tune zerolatency -f rawvideo

これをそのままWebSocketで送信できれば、上記デモと全く同じ仕組みで、ブラウザ上にリアルタイムカメラ画像を取得可能。

2015/11/09(月)IPカメラ(Webcam)リレープログラム / ip-camera-relay.pl

IP Cameraの映像を中継するプログラムです。以下の機能があります。

  • IPカメラへの自動再接続。
  • 複数のIPカメラを登録し、接続できたカメラの映像を中継する。接続が切れたら、次に接続できたカメラに接続する。
  • IPカメラに接続できていない時は、指定した画像(ブランクイメージ)を代わりに送信する。

VLCなどのIPCamストリーム受信ソフトでは、カメラとの接続が切れた場合に自動再接続を行ってくれないものがあります。またカメラと接続が切れたときにブランク画像を挿入したいこともあり、それを実現するためのソフトになります。

プログラム

Gistに置いたので、適当に拾ってください。

使い方

$ ip-camera-relay.pl -p 8888 http://(camera-ip):port/stream-path

クライアント接続用に8888番ポートを開き、指定したカメラに接続します。カメラURLはブラウザから接続するURLではなく、ビデオストリームのURLを指定してください*1

複数のカメラを指定すると、それぞれを順番に接続します。最初に接続できたストリームをクライアントに配信し、カメラとの接続が切れたら次に接続できたカメラの映像を送ります。

どのカメラとも接続できない時は、ブランク画像(標準では640x480の青い画像)を送信します。

その他オプションはヘルプを参照してください。

*1 : ブラウザから接続したりカメラの仕様書を見れば、書かれているかと思います。

WebCamのプロトコル

続きを読む

2015/11/06(金)UPnP/SSDPマルチキャスト中継プログラム(DLNA用)

UPnPのマルチキャストパケットを中継するためのプログラムです。通常、同一ネットワーク内でしか使用できないUPnPを、ルーターを超えて使用できるようになります。

想定

  • DLNAサーバとクライアント(またはコントローラーとレンダー)がルーターを超えて存在する。
  • それぞれネットワークAとBとすれば、A内のPCとB内のPCは互いに通信可能である(NATされていない)。
  • どちらのネットワークにも属するPCが存在する。

「WiFiと有線LAN環境を同一ネットワークにしたくない」ただそれだけなのですが、そのせいでUPnP/DLNAが超えられず大変苦労しました……。

続きを読む

2008/01/15(火)WindowsですべてのインターフェイスからUDPブロードキャスト

要:winsock2

INTERFACE_INFO if_list[20];
unsigned long ifsize;
if (WSAIoctl(sock, SIO_GET_INTERFACE_LIST, 0, 0, &if_list,
	sizeof(if_list), &ifsize, 0, 0) == SOCKET_ERROR)
		throw("Failed Interface List");
int if_num = ifsize / sizeof(INTERFACE_INFO);
for (int i=0; i<if_num; i++) {
	sockaddr_in *adr_ip, *adr_net;
	adr_ip  = (sockaddr_in *)&if_list[i].iiAddress;
	adr_net = (sockaddr_in *)&if_list[i].iiNetmask;
	sockaddr.sin_addr.s_addr  = adr_ip ->sin_addr.s_addr;
	sockaddr.sin_addr.s_addr &= adr_net->sin_addr.s_addr;
	sockaddr.sin_addr.s_addr |= ~(adr_net->sin_addr.s_addr);
	// strcpy(s,inet_ntoa(sockaddr.sin_addr));
	sendto(sock, "Find", 4, 0, (struct sockaddr *)&sockaddr, sizeof(struct sockaddr));
}

2007/05/23(水)UDPをTCPにクローンするプログラム

UDPを受信してTCPにクローンするプログラム(複数クライアント対応)。本当はマルチスレッドで書いてあげるべきなのですが、そこまですると(実現する処理に対して)高級するぎる気がしたのでポーリングにしました。

というのも、TCPの通信エラーとかが出たときに(ブロックしないはずの)ソケットへの書き込み(データの送信)がバッファ一杯になるとストールして(止まって)しまうんですよね。もちろんソケットや書き込み時の関数はノンブロッキングに設定しておいても、です。C ではまだ確認していませんが、Perlで書いたときはそうでした。

TCP/IPはただ通信するだけなら簡単ですが、この手のエラー処理を考えはじめると非常に頭を使います。

プログラム

メイン部のみ。適当に main ルーチンを書けば Windows でも Unix系 でも動きます。

int	max_connections=100;
char	buffer[0x10000];
int	connection_pool[max_connections];

int accept_client(int listen_sock) {
	int sock, len;
	struct sockaddr_in sin;

	// accept
	len = sizeof(sin);
	sock = accept(listen_sock, (struct sockaddr *)&sin, &len);
	if (sock<0) error_return("client accept error");

	// 接続者情報
	printf("[%02d] Connection from %s\n", sock, inet_ntoa(sin.sin_addr));
	// ソケットの設定
	set_non_blocking(sock);
	return sock;
}

//////////////////////////////////////////////////////////////////////////////
// server main
//////////////////////////////////////////////////////////////////////////////
int udp2tcp_server_main(int udp_sock, int tcp_sock) {
	int i;
	char buf[1024];
	// select用の設定
	fd_set fdbits;
	fd_set fdbits_org;
	FD_ZERO(&fdbits_org);
	FD_SET(udp_sock, &fdbits_org);
	FD_SET(tcp_sock, &fdbits_org);

	while(1) {
		// Select
		memcpy(&fdbits, &fdbits_org, sizeof(fdbits_org));
		select(FD_SETSIZE, &fdbits, NULL, NULL, NULL);

		// New connection from TCP
		if ( FD_ISSET(tcp_sock, &fdbits) ) {
			int newsock = accept_client(tcp_sock);
			if (newsock>0) {
				// search for free connection pool point
				for(i=0; i<max_connections; i++)
					if (!connection_pool[i]) break;

				if (i<max_connections) {	// ソケット番号をセーブ
					FD_SET(newsock, &fdbits_org);
					connection_pool[i] = newsock;
					dbg("[%02d] save to connection_pool[%d]\n", newsock, i);
				} else {		// 接続を切る
					printf("Connections max\n");
					close(newsock);
				}
			}
		}

		// Recieved from UDP
		int size = 0;
		if ( FD_ISSET(udp_sock, &fdbits) ) {
			size = recv(udp_sock, buffer, BUF_SIZE, MSG_DONTWAIT);
			if (size<0) error_exit2("UDP recv error(%d)", size);
			dbg("[%02d] Recieved UDP %d bytes\n", udp_sock, size);
		}

		// TCP接続クライアントにデータを送信
		for(i=0; i<max_connections; i++) {
			int sock = connection_pool[i];
			if (!sock) continue;
			// socket からデータ受信
			if ( FD_ISSET(sock, &fdbits) ) {
				int s = recv(sock, buf, 1024, MSG_DONTWAIT);
				if (s<0) {
					FD_CLR(sock, &fdbits_org);
					connection_pool[i] = 0;
					dbg("[%02d] clear to connection_pool[%d]\n", sock, i);
					printf("[%02d] Connection close\n", sock);
					close(sock);
					continue;
				}
			}
			// データ送信
			if (size>0) {
				//int s = write(sock, buf, size);
				int s = send(sock, buf, size, MSG_DONTWAIT);
				dbg("[%02d] write TCP %d bytes\n", sock, s);
			}
		}
	}
}