リポジトリと完成品はこちら
https://github.com/amate/VideoConvertStreamServer
動機
皆さんはPCにある動画ファイルを、スマホやタブレットからどうやって見ていますか?
自分はSMB経由で、Kodiというメディアプレイヤーを使って見ていましたが、
自分の所有するfireHDから録画したtsファイルを見る場合、しばしば問題が起きました
- シークが物凄く遅い
- プレイヤーに秒単位のスキップ機能がなく、細かいシークバーの操作を要求される
- インターレース解除なんてしてくれないので、映像にシマシマが出てしまう
- ファイルの状態によっては、映像が崩壊してまともに見られない
パソコンで見た場合、映像は崩壊せず普通に再生されてしまうのが困ったところ
tsファイルを再生できる有名どころのプレイヤーは一通り試した後で、ようやく見つけたプレイヤーでしたが、上記の問題にずっと悩んでいました
どうすればいいんだろうと延々考えていると
ふと、最近のブラウザは動画を再生できたなぁ、そういやvideoタグなんてものが追加されたような・・・
そこで突如閃く!
PCにある動画をストリーミングして、ブラウザで再生すればいいじゃないか!
脳みそFLASH1世代が辿り着いた逆転のアイデア
動画プレイヤー側を改善するのではなく、動画配信側を改善する発想
こうして、動画をストリーミングするサーバーを開発することを決心したのであった
試作
アイデアは思いついたが、実際に実現できるかどうかを、まずは検証しなければならない
当たり前だが、videoタグの"src"にtsファイルのパスを書き込んでも再生できないが、ブラウザでストリーミングされた動画を再生する技術には心当たりがあった
HTTP Live Streaming すなわち HLS2 である
HLSの詳細は説明しないが、HLSの優れている点は、HTTPプロトコルでファイルを配信さえできれば、PCのブラウザに限らず、スマホやタブレットのブラウザで動画のストリーミング視聴が可能なことである
よし早速HLSで動画配信だ!とはいかない
HLSを使うためには、動画を変換して細かく分割した"セグメントファイル(segment00xx.ts)"と、セグメントファイルの場所などを記録した"インデックスファイル(.m3u8)"の二つを用意しなければならない
自分で動画を変換するのは大変なので、外部のプログラムを使うことにした
皆さんご存じの ffmpeg だ
ffmpegを使えばセグメントファイルからインデックスファイルまで自動で生成してくれる
なんて便利なんだ!
Windows向けの実行ファイルを配布してくれているサイトからzipファイルをダウンロードして、適当なフォルダに解凍した後に、ffmpeg.exeを叩けるようにコマンドプロンプトを開いて、以下のコマンドを入力すると、ffmpegが起動し動画を変換しながらセグメントファイルに分割して、インデックスファイルを作成してくれる
ffmpeg.exe -i "" -map 0:v -map 0:a -c:v libx264 -preset veryfast -crf 23 -keyint_min 30 -g 30 -c:a aac -b:a 192k -f hls -hls_time 5 -hls_list_size 0 -hls_segment_filename "segment_%08d.ts" "index.m3u8"
それぞれの引数の意味を軽く説明すると
-map 0:v -map 0:a
tsファイルの映像と音声を変換するという指定
これが無いとtsファイルに入ってる字幕も変換しようとしてエンコードがコケる場合がある
-c:v libx264
映像形式はH.264である必要がある
-preset veryfast
映像の圧縮率と変換速度の指定
あまり変換に時間がかかっても困るのでveryfast指定
-crf 23
動画の品質の指定
数字が少ないほど高品質でファイルサイズ増、数字が大きいほど低品質ファイルサイズ減
-keyint_min 30 -g 30
キーフレームの最小間隔を30フレームに、最大GOP長を30フレームに
これを指定しておかないと、hls_timeで指定したセグメントファイルの再生時間が数秒上下してしまう
-c:a aac
音声はaac形式に
-b:a 192k
音声のビットレートは192kbps
-f hls
フォーマット指定
変換した動画のコンテナをMPEG-TS形式にしてくれる
-hls_time 5
セグメントファイルの分割を5秒単位にする
-hls_list_size 0
インデックスファイルに、すべてのセグメントファイルの名前が書かれるようにする
これを指定しないと、インデックスファイルには、生成したセグメントファイルの最新5個分の名前しか記載されなくなる
-hls_segment_filename "segment_%08d.ts" "index.m3u8"
セグメントファイルとインデックスファイルのファイル名を指定する
閑話休題
エンコードが終わると、フォルダ内には大量の segment_xxxx.tsファイルと index.m3u8 ファイルが生成されているはずなので、
早速ブラウザで再生できるかどうかテストします
<html>
<head>
</head>
<body>
<video src="index.m3u8" controls></video>
</body>
</html>
上記の内容のhtmlファイルを同じフォルダに作成して、firefoxで開いてみると・・・
再生できない/(^o^)\
・・・
調べてみると、firefoxやchromeはデフォルトではHLS形式のm3u8を再生してくれない模様
Edgeやsafariなら再生してくれるようなので、Edgeで開いてみると・・・
無事再生できた!
一連の行為から、tsファイルを変換して、ブラウザで動画再生が可能なことを実証できたので、実際にサーバーを作っていきます
HTTPサーバー作成
HTTPサーバーをどんな言語で、どのようなライブラリを使用して作るかだが、
自分は普段C++を使っており、以前私用で通信にboost::asioを利用した超簡易HTTPサーバーを作っていたので、それを流用することにする
正直今更C++でHTTPサーバー作るのかよと思わなくもないが、自分が経験したことを流用でき、早く完成させることを目的にしたので、まぁいいかなと
そのうち暇になったら他の言語でも作ってみてもいいかもしれない
言語が決まったら早速、素人が購入することは考えにくい、プログラム開発者らが使用する高価な専門ツールであるVisualStudio2019でプロジェクトを作成する
プロジェクトのテンプレートはコンソールアプリでいいだろう
後々GUIを付けたくなったらその時に考える
ここで悩むのがプロジェクト名、アプリケーションの名前にもなるので慎重に考える
プロジェクト名はユニークな方がいい、自分はGoogle検索で1件も出ないことを確認してから付けている
安直だが"VideoConvertStreamServer"と名付けた
プロジェクトを開いたら、まずは"デバッグなしで開始"を実行
"Hello World!"が表示されるのを確認する
問題なくコンパイルが通ることを確認したら、いよいよ開発開始だ
以前作った超簡易HTTPサーバーのソースをコピーする
#pragma once
#include <memory>
#include <thread>
#include <boost\range.hpp>
#include <boost\asio.hpp>
class HttpServer
{
public:
static std::shared_ptr<HttpServer> RunHttpServer();
void StopHttpServer();
private:
HttpServer();
std::thread m_serverThread;
boost::asio::io_service m_ioService;
};
#include "stdafx.h"
#include "HttpServer.h"
#include <regex>
#include <fstream>
#include <sstream>
#include <boost\asio\spawn.hpp>
#include "Utility\CommonUtility.h"
#include "Utility\Logger.h"
#include "Utility\CodeConvert.h"
namespace asio = boost::asio;
using boost::asio::ip::tcp;
using namespace CodeConvert;
namespace {
enum { kDefaultHttpServerPort = 4789 };
void connection_rountine(asio::yield_context& yield, tcp::socket s)
{
ATLTRACE("enter connection_rountine\n");
static const fs::path htmlFolderPath = GetExeDirectory() / L"html";
try {
for (;;) {
boost::system::error_code ec;
asio::streambuf buf;
std::string str;
auto const n = asio::async_read_until(s, buf, "\r\n\r\n", yield[ec]);
//auto const n = asio::async_read(s, buf, asio::transfer_at_least(200), yield[ec]);
if (ec) break;
// ヘッダ読み込み
str.assign(asio::buffer_cast<const char*>(buf.data()), n);
buf.consume(n);
// レスポンス送信
asio::async_write(s, asio::buffer("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\nunknown operation"), yield[ec]);
if (ec) break;
s.close();
break;
}
} catch (std::exception& e) {
//ATLASSERT(FALSE);
ATLTRACE("error : %s\n", e.what());
ERROR_LOG << L"connection_rountine - exception throw: " << UTF16fromShiftJIS(e.what());
}
ATLTRACE("leave connection_rountine\n");
}
} // namespace
std::shared_ptr<HttpServer> HttpServer::RunHttpServer()
{
auto server = std::shared_ptr<HttpServer>(new HttpServer);
return server;
}
void HttpServer::StopHttpServer()
{
m_ioService.stop();
m_serverThread.join();
}
HttpServer::HttpServer()
{
m_serverThread = std::thread([this] {
INFO_LOG << (L"HttpServer start!") << L" port: " << kDefaultHttpServerPort;
asio::spawn(m_ioService, [&](asio::yield_context yield) {
tcp::acceptor acceptor(m_ioService, tcp::endpoint(tcp::v4(), kDefaultHttpServerPort));
for (;;) {
tcp::socket socket(m_ioService);
acceptor.async_accept(socket, yield);
asio::spawn(m_ioService, [&](asio::yield_context yc) {
connection_rountine(yc, std::move(socket));
});
}
});
m_ioService.run(); // このスレッドで実行する
INFO_LOG << (L"HttpServer finish!");
});
}
サーバーの立ち上げ方は簡単
auto httpServer = HttpServer::RunHttpServer();
上記のコードを適当な場所に書いてあげれば、kDefaultHttpServerPortで接続の待ち受けを開始します
今は適当にmainに書いときます
#include "stdafx.h"
#include <iostream>
#include "Utility/Logger.h"
#include "Utility/CommonUtility.h"
#include "HttpServer.h"
int main()
{
std::cout << "Hello World!\n";
auto httpServer = HttpServer::RunHttpServer();
for (;;) { // 終了しないために必要
::Sleep(1000);
}
}
プログラムを実行すると
http://127.0.0.1:4789/
ブラウザから上記のURLに接続できるようになっています
ブラウザで開いて、"unknown operation"と表示されれば成功
今は本当に何の機能もないので、とりあえず、アクセスされた場合"実行ファイルがあるフォルダ\html\"フォルダにあるファイルを返すようプログラムします
// ヘッダ読み込み
str.assign(asio::buffer_cast<const char*>(buf.data()), n);
buf.consume(n);
std::regex rx("^GET ([^ ]+)");
std::smatch result;
if (std::regex_search(str, result, rx)) {
std::string path = result[1].str();
INFO_LOG << L"GET " << ConvertUTF16fromUTF8(path);
// =====================================================
// htmlフォルダ以下の その他ファイルへのリクエスト
auto actualPath = htmlFolderPath / path.substr(1);
if (fs::is_regular_file(actualPath)) {
SendFile200OK(actualPath, yield, s);
break;
}
}
// ヘッダ読み込み
の下にコードを追加
std::regex rx("^GET ([^ ]+)");
std::smatch result;
if (std::regex_search(str, result, rx)) {
std::string path = result[1].str();
正規表現を使って、GETリクエストからパスを取り出します
static const fs::path htmlFolderPath = GetExeDirectory() / L"html";
auto actualPath = htmlFolderPath / path.substr(1);
取り出したパスから、PC内のファイルの位置に変換
if (fs::is_regular_file(actualPath)) {
SendFile200OK(actualPath, yield, s);
break;
}
ファイルが存在すれば、"HTTP/1.1 200 OK"と共に、ブラウザにファイルを送信します
void SendFile200OK(const fs::path& filePath, asio::yield_context& yield, tcp::socket& s)
{
auto fileSize = fs::file_size(filePath);
std::ifstream fs(filePath.wstring(), std::ios::in | std::ios::binary);
if (!fs) {
ATLASSERT(FALSE);
ERROR_LOG << L"file open failed: " << filePath.wstring();
return;
}
std::string contentType = GetFileContentType(filePath);
if (contentType == "text/plain") {
contentType += "; charset=utf-8";
}
std::stringstream ss;
ss << "HTTP/1.1 200 OK\r\n"
<< "Content-Type: " << contentType << "\r\n"
<< "Content-Length: " << fileSize << "\r\n"
<< "\r\n";
std::string header = ss.str();
boost::system::error_code ec;
asio::async_write(s, asio::buffer(header), yield[ec]);
if (ec) {
ERROR_LOG << L"SendFile200OK - async_write failed: " << UTF16fromShiftJIS(ec.message());
return;
}
enum { kReadBufferSize = 4 * 1024 };
auto buffer = std::make_unique<char[]>(kReadBufferSize);
std::streamsize totalReadCount = 0;
do {
fs.read(buffer.get(), kReadBufferSize);
auto readCount = fs.gcount();
totalReadCount += readCount;
asio::async_write(s, asio::buffer(buffer.get(), readCount), yield[ec]);
if (ec) {
ERROR_LOG << L"SendFile200OK - async_write failed: " << UTF16fromShiftJIS(ec.message());
return;
}
} while (fs.good());
INFO_LOG << L"\tsend: " << totalReadCount << L" bytes";
s.close();
}
SendFile200OKの内容は、ファイルを読んでブラウザに送信する処理です
まんまですね
一点ブラウザにデータを送る部分で、重要な処理があります
それは、データの内容によって、正確な"Content-Type"を設定することです
ブラウザは拡張子なんて見ないので、自分が送ったデータがhtmlファイルなのか、それとも動画ファイルなのか、それとも画像ファイルなのかを"Content-Type"を使って伝えてあげなくてはいけません
const std::unordered_map<std::string, std::string> kExtContentType =
{
{".html", "text/html"},
{".js", "text/javascript"},
{".css", "text/css"},
{".woff2", "font/woff2"},
{".txt", "text/plain"},
{".log", "text/plain"},
{".m3u8", "application/x-mpegURL"},
{".ts", "video/mp2t"},
{".png", "image/png"},
{".jpg", "image/jpeg"},
{".jpeg", "image/jpeg"},
};
std::string GetFileContentType(const fs::path& filePath)
{
std::string ext = filePath.extension().string();
boost::to_lower(ext);
std::string contentType;
auto itfound = kExtContentType.find(ext);
if (itfound != kExtContentType.end()) {
contentType = itfound->second;
} else {
contentType = "application/octet-stream";
}
return contentType;
}
このプログラムでは、拡張子から変換テーブルを使って"Content-Type"を決定しています
ここまで書いたらコンパイルして実行
htmlフォルダ以下に適当な画像ファイルでも置いて、ブラウザでアクセスして、置いた画像ファイルが表示されれば成功
次は、ブラウザから見たい動画ファイルを選べるように、ファイルリスト画面を作ります
要するに、こんな画面を作っていきます
ここからはサーバーではなく、ウェブページの編集が必要になってくるので、先に環境を整えます
まずは、htmlとjavascriptの編集のために、Visual Studio Codeというエディタを導入
導入出来たら次は拡張機能を入れます
- 日本人なので - Japanese Language Pack for VS Code
- リアルタイムでhtmlページの編集結果をプレビューできる - HTML Preview
- VSCode内でJavascriptのデバッグができる - Debugger for Firefox
拡張機能を入れたら、[ファイル]->[フォルダを開く]で、htmlフォルダを開きます
その後、[実行]->[構成の追加]で、"Firefox"を選択
launch.jsonが開かれるので、
"http://localhost/index.html"をhttp://localhost:4789/"に変更
"Launch localhost"を選択して、実行を押せばデバッグ用のfirefoxが立ち上がります
以下立ち上がったfirefoxを使ってウェブページを構築していきます
環境構築が終わったので、早速ウェブぺージを作っていきますが、自分には1から作っていくデザイン知識やセンスがないので、bootstrapというフレームワークの力を借ります
あのTwitter社が開発しているフレームワークで、誰でも簡単にオサレなサイトが作れてしまう・・・かもしれない
https://getbootstrap.com/docs/4.5/getting-started/introduction/
早速 root.htmlという名前のファイルを作り、introductionからテンプレートをパクってきます
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
<title>Hello, world!</title>
</head>
<body>
<h1>Hello, world!</h1>
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
</body>
</html>
http://localhost:4789/root.html
を開いて、"Hello, world!"と表示されれば成功
この時点では、まだオサレには見えないが、だんだんとオサレになっていきます
https://getbootstrap.com/docs/4.5/content/tables/
ファイルリストを表示したいので、tableからサンプルをコピーします
<body>
<div class="container">
<h1>Hello, world!</h1>
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">First</th>
<th scope="col">Last</th>
<th scope="col">Handle</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1</th>
<td>Mark</td>
<td>Otto</td>
<td>@mdo</td>
</tr>
<tr>
<th scope="row">2</th>
<td>Jacob</td>
<td>Thornton</td>
<td>@fat</td>
</tr>
<tr>
<th scope="row">3</th>
<td>Larry</td>
<td>the Bird</td>
<td>@twitter</td>
</tr>
</tbody>
</table>
</div>
いい感じのテーブルが表示されましたね
このテーブルをファイルリストにするためには、PC内にあるファイルの情報を、どうにかこうにか取得しなければなりません
そのため、今度はサーバー側にファイルの情報を取得するAPIを実装します
INFO_LOG << L"GET " << ConvertUTF16fromUTF8(path);
if (path.substr(0, 12) == "/filetreeAPI") {
auto queryData = ConvertUTF16fromUTF8(URLDecode(path.substr(12)));
std::wregex rx(LR"(\?path=\/root\/(.*)");
std::wsmatch result;
if (std::regex_match(queryData, result, rx)) {
auto searchFolderPath = result[1].str();
FileTreeAPI(searchFolderPath, yield, s);
}
break;
}
connection_rountine関数の
INFO_LOG << L"GET " << ConvertUTF16fromUTF8(path);
の行以下に filetreeAPIを実装していきます
if (path.substr(0, 12) == "/filetreeAPI")
これは、"http://localhost:4789/filetreeAPI" へのアクセスがあったときに処理を分岐します
auto queryData = ConvertUTF16fromUTF8(URLDecode(path.substr(12)));
GETリクエストのパスは、URLエンコードされた状態で送られてくるので、元の文字列に復元してやる必要があります
std::string URLDecode(const std::string& src)
{
auto funcHexToInt = [](char c) -> int {
if ('0' <= c && c <= '9') {
return c - '0';
} else if ('a' <= c && c <= 'f') {
return c - 'a' + 10;
} else if ('A' <= c && c <= 'F') {
return c - 'A' + 10;
} else {
ATLASSERT(FALSE);
return 0;
}
};
std::string dest;
for (auto it = src.begin(); it != src.end(); ++it) {
switch (*it)
{
case '%':
{
++it;
ATLASSERT(it != src.end());
char c1 = *it;
++it;
ATLASSERT(it != src.end());
char c2 = *it;
char destc = static_cast<char>((funcHexToInt(c1) << 4) | funcHexToInt(c2));
dest.push_back(destc);
}
break;
case '+':
{
dest.push_back(' ');
}
break;
default:
dest.push_back(*it);
break;
}
}
return dest;
}
char destc = static_cast<char>((funcHexToInt(c1) << 4) | funcHexToInt(c2));
大事なのは、パーセントエンコーディングされた部分を復元する処理です
二つの16進数を数値に変換し、左の数値をビットシフトで4回左にずらして、右の数値とビット論理和で8bitの数値を作り出します
続きの記事
https://qiita.com/amate/items/f705593986b23f1c8032
-
最近めっきり見なくなりましたね… ↩
-
HLSの詳細についてはこの辺りを見てください https://qiita.com/STomohiko/items/eb223a9cb6325d7d42d9 https://jp.vcube.com/sdk/blog/hls-http-live-streaming.html ↩