はじめに
RustでRTMPを受信してHLSで配信するサーバーの、HLSの部分を上手に作れなくて困っていたので、とりあえず簡単に動くHLS配信サーバーを作成して整理してみようということで作成してみました。
HLSの動作確認には、Safariを使用しました。普段はFirefoxを使っているのですが、Safariはm3u8ファイルを直接再生することができるので、localhostのアドレスにSafariから直接GETリクエストを送信して動画を再生していました。(Firefoxだとファイルをダウンロードするように表示されてしまう。)
m3u8ファイルとtsファイル
HLSのストリーミングには、どちらも聞き慣れない名前でしたがtsファイルとm3u8ファイルというものが登場します。
tsファイルは、動画を分割して小分けにしたファイルです。このtsファイルを連続で再生していくことで、一つの動画としてストリーミングすることができます。
m3u8ファイルは、tsファイルを正しい順序や方法で再生するためのプレイリストファイルのようなものです。
MasterPlaylistとMediaPlaylist
そのm3u8ファイルにも、MasterPlaylistとMediaPlaylistの2種類があります。
MediaPlaylistは、tsファイルを再生する順序や方法を指定するファイルで、このような形になっています。
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:60
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:40,
sample.ts
#EXT-X-TARGETDURATION
は、そのプレイリスト(m3u8)に含まれているtsファイルの長さの最大値を示しています。そして#EXTINF
はtsファイルの秒数を示しています。なので、#EXT-X-TARGETDURATION
が#EXTINF
よりも小さくなってしまうとストリーミングが正常に再生されません。ここを直すのに結構時間を食っちゃいました。
MasterPlaylistは、複数のMediaPlaylistをまとめておくことができるプレイリストです。
なんのために使うのかというと、通信速度や処理できるファイル形式、ビットレートごとにどのMediaPlaylistを再生すればいいかをクライアントに判断させるためにあります。
ざっくりとこのような形になっています。(今回は動くかどうかを確認しただけなのでビットレートごとに分かれたりしていないです。)
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="1",NAME="sample"
#EXT-X-STREAM-INF:BANDWIDTH=640000
http://localhost:8001/stream/media/playlist.m3u8
実際のプログラム
Rustで配信サーバーを作成したので、もちろん言語はRustです。
main.rs
に全部書いちゃったのですが、記述量はすごく多くはないと思います。
#![feature(proc_macro_hygiene, decl_macro)]
extern crate m3u8_rs;
#[macro_use]
extern crate rocket;
use std::io::Cursor;
use std::fs;
use std::path::PathBuf;
use m3u8_rs::playlist::{
MasterPlaylist,
MediaPlaylist,
MediaSegment,
VariantStream,
AlternativeMedia,
AlternativeMediaType,
};
use rocket::http::{ContentType, Status};
use rocket::response::Response;
use tempfile;
#[get("/stream/media/<file_name>")]
fn play_file<'r>(file_name: String) -> Response<'r> {
println!("play_file called");
let path = format!("./assets/{}", file_name);
let filepath = PathBuf::from(path);
match fs::read(&filepath) {
Ok(file) => {
let response = Response::build()
.status(Status::Ok)
.header(ContentType::new("application", "x-mpegURL"))
.raw_header("Accept-Ranges", "bytes")
.raw_header("Connection", "keep-alive")
.sized_body(Cursor::new(file))
.finalize();
response
},
Err(e) => {
println!("{}", e);
let response = Response::build()
.status(Status::Ok)
.header(ContentType::Plain)
.sized_body(Cursor::new("Error"))
.finalize();
response
}
}
}
#[get("/stream/playlist.m3u8")]
fn stream<'r>() -> Response<'r> {
println!("stream called");
let path = "./assets/master_playlist.m3u8";
let filepath = PathBuf::from(path);
match fs::read(&filepath) {
Ok(file) => {
let response = Response::build()
.status(Status::Ok)
.header(ContentType::new("application", "x-mpegURL"))
.raw_header("Accept-Ranges", "bytes")
.raw_header("Connection", "keep-alive")
.sized_body(Cursor::new(file))
.finalize();
response
},
Err(e) => {
println!("{}", e);
let response = Response::build()
.status(Status::Ok)
.header(ContentType::Plain)
.sized_body(Cursor::new("Error"))
.finalize();
response
}
}
}
fn main() {
let mut playlist = Playlist::new();
playlist.add_media_segment();
playlist.add_master_playlist();
rocket::ignite()
.mount("/", routes![play_file, stream])
.launch();
}
struct Playlist {
master_playlist: MasterPlaylist,
playlist: MediaPlaylist,
}
impl Playlist {
fn new() -> Self {
let mut playlist = MediaPlaylist::default();
playlist.version = 3;
playlist.target_duration = 60.0;
playlist.media_sequence = 0;
let mut master_playlist = MasterPlaylist::default();
master_playlist.version = 3;
Self {
master_playlist: master_playlist,
playlist: playlist
}
}
#[allow(unused_must_use)]
fn add_media_segment(&mut self) {
let mut segment = MediaSegment::empty();
segment.duration = 40.0;
segment.title = Some("".into());
segment.uri = "sample.ts".to_string();
self.playlist.segments.push(segment);
self.playlist.media_sequence = 1;
let hls_root = PathBuf::from("./assets");
let mut tmp_file = tempfile::Builder::new()
.prefix("playlist.m3u")
.suffix(".tmp")
.tempfile_in(hls_root)
.unwrap();
self.playlist.write_to(&mut tmp_file);
fs::rename(&tmp_file.path(), "./assets/playlist.m3u8");
}
#[allow(unused_must_use)]
fn add_master_playlist(&mut self) {
let mut alt_media = AlternativeMedia::default();
//alt_media.uri = Some("playlist.m3u8".to_string());
alt_media.media_type = AlternativeMediaType::Video;
alt_media.group_id = "1".to_string();
alt_media.name = "sample".to_string();
let mut variant_stream = VariantStream::default();
variant_stream.is_i_frame = false;
variant_stream.uri = "http://localhost:8001/stream/media/playlist.m3u8".to_string();
variant_stream.alternatives.push(alt_media);
variant_stream.bandwidth = "640000".to_string();
self.master_playlist.variants.push(variant_stream);
let hls_root = PathBuf::from("./assets");
let mut tmp_file = tempfile::Builder::new()
.prefix("master_playlist.m3u")
.suffix(".tmp")
.tempfile_in(hls_root)
.unwrap();
self.master_playlist.write_to(&mut tmp_file);
fs::rename(&tmp_file.path(), "./assets/master_playlist.m3u8");
}
}
流れ
ざっくりと解説しようと思います。
使ったクレートは、tempfileというものと、有名なrocketと、m3u8を作るためのm3u8-rsです。
まず最初に、Playlist
構造体というものを用意しておきます。MasterPlaylistを保持するフィールドと、MediaPlaylistを保持するフィールドを持っています。そしてそれぞれのフィールドに色々と設定を書き加えたプレイリストを格納します。
次は、rocketを使ってHTTPのレスポンスを用意します。
#[get(...)]
という属性が付いている関数が、ルーティングの関数になっています。2つ関数があり、それぞれMasterPlaylistとMediaPlaylistを扱うようになっています。どちらを使っても動きますが、Accept-Ranges
のヘッダがないとSafariで再生できなかったのでraw_header()
を使ってヘッダをつけておくことに注意しておくといいかもしれません。
ざっくりとこんな感じの記述で、HLS配信を実現できました。
さいごに
ヘッダやm3u8の中身をちゃんと設定していないとちゃんと再生されず、サーバー作成にけっこう時間がかかってしまいました。実際はRTMPを受信してHLSを配信するサーバーを作成しないといけないので、これを参考にしながら既存のRTMPサーバーに改良を加えていこうと思います。