ygoto3.com

Software engineer at CyberAgent.

フロントエンドエンジニアのための生放送と RTMP 通信基礎

生放送と RTMP 通信基礎

前回「フロントエンドエンジニアのための動画ストリーミング技術基礎」では HTTP ベースのストリーミング技術に関して勉強会を実施しました。視聴者に映像を届けるためのストリーミング技術に関してのお話でした。

本記事は、AbemaTV の生放送番組で撮影機材から送られた映像がエンコーダーを介してリアルタイムに放送する部分について勉強会を実施した際の資料です。

生放送における動画データの通信

AbemaTV では生放送で撮影した動画データのやりとりに RTMP というプロトコルを利用しています。

生放送の配信構成

RTMP とは

RTMP は Real-Time Message Protocol の略で、その名前の通りリアルタイムにコミュニケーションを行うためのプロトコルです。Web 業界では Photoshop などでお馴染の Adobe Systems 社が開発しています。Adobe Flash Player がメディア配信サーバーとの間で音声や動画などのデータをやりとりするためのストリーミングのためのプロトコルとして開発されました。

前回紹介した HLS や MPEG-DASH もストリーミングプロトコルでしたが、RTMP はこれらと異なり、HTTP ベースではありません。ですので、HLS や MPEG-DASH のように通常の Web サーバーでコンテンツを配信できるわけではなく、専用の RTMP サーバーが必要になります。

RTMP 専用サーバー

HTTP に対する優位性

HTTP における通信は必ずクライアントのリクエストから始まります。そのため、動画をストリーミングしようとする際、クライアントはサーバーに対して任意のインターバルで動画のセグメントをリクエストし続ける必要があります。HLS や MPEG-DASH におけるストリーミングはこの方式ですが、クライアントのタイミングで動画データをリクエストするため、本当の意味でのリアルタイム性はありません。動画データを生成しているサーバー側が送信したいタイミングでクライアントにデータをプッシュできる方が遅延が発生することがなく、効率的です。

それに対して、RTMP におけるデータ通信は持続的に接続した状態で双方向に行われます。そのため、サーバーがクライアントに送信したいタイミングでプッシュ送信することができ、遅延が発生しません。

また HTTP の場合、HTTP レスポンスヘッダーは冗長で一般的に数百バイトになります。返したいペイロードサイズに対してのオーバーヘッドを大きくしてしまっています。それに対し、RTMP パケットのヘッダーは固定長で 12 / 8 / 4 / 1 バイトのうちどれかになり、ペイロードサイズに対するオーバーヘッドは小さいです。

生放送の現場ではリアルタイム性を重視

生放送の配信構成

AbemaTV の生放送の現場では撮影機材で撮れた映像を Wirecast などのエンコーダーでエンコードし、それを RTMP 通信で Wowza などのメディアストリーミングサーバーに届けています。そして、メディアストリーミングサーバーに届けられた映像を確認しながら、生で撮影しているその映像に対して遅延を極力少ない状態で CM 入りや視聴者参加型コンテンツなどのトリガーとなるシグナルを通信できる環境を構築しています。

RTMP の種類

RTMP にはいくつかの派生種があります。

  • RTMPT - HTTP でカプセル化した RTMP
  • RTMPS - TLS/SSL で暗号化して HTTPS でカプセル化した RTMP
  • RTMPE - こちらも暗号化された RTMP ですが、設計に欠陥があり RTMPS の使用が推奨されている
  • pRTMP - Adobe Primetime DRM がかかった RTMP

RTMP は一般的に 1935 ポートを使用します。しかし、セキュリティの厳しい環境ではこの 1935 ポートが使えないこともしばしばあります。そのため、HTTP( 80 ポート)や HTTPS( 443 ポート)を装って通信するという手段を取ることが可能です。それが RTMPTRTMPS になります。

更にリアルタイム性を重視したデータ通信

RTMP は TCP を利用したプロトコルですが、別に RTMFP という UDP を利用したプロトコルもあります。UDP を利用するプロトコルは TCP を利用するプロトコルと比べて通信速度面において利点があります。TCP はパケット・ロストに対して再送する仕組みですが、UDP はパケット・ロストに対して再送することはありません。その分再送のオーバーヘッドなく通信することができます。

データ量の大きな双方向データ通信

RTMP は双方向のデータ通信が可能なプロトコルですが、両方向とも送信するデータ量が大きな通信サービスを構築する場合は UDP ベースの RTMFP が好まれます。たとえばテレビ会議やビデオチャットなどは双方が送信するデータが動画のため、データサイズが大きいにも関わらず、スムーズなコミュニケーションのためにリアルタイム性が求められます。

TCP で パケット・ロストによる再送で遅延の頻度が高まると、音声や映像が遅れた状態になる可能性が高くなります。特にテレビ会議やビデオチャットなどはデータの抜け落ちが発生したとしてもノイズ程度の劣化として許容できる場合がほとんどなため、UDP での通信が向いています。

RTMP をサポートするメディアストリーミングサーバ

RTMP でストリーミング配信するには、専用の RTMP サーバーが必要です。ここでは RTMP をサポートする代表的なメディアストリーミングサーバーを3つ紹介します。

Adobe Media Server

Adobe Media Server

Adobe Systems 社が開発しているメディアストリーミングサーバーです。Flash 技術の総本山である Adobe が開発しているだけあり、ここで紹介するメディアサーバーの中で一番知名度が高く、機能も豊富です。そしてその分ライセンス料も高いです。(RTMFP のサポート有無など機能数に応じて複数のエディションに別れています。)

参照:Adobe Media Serverファミリー

Wowza Media Server

Wowza Media Systems

Wowza Media Systems 社によって開発されているメディアストリーミングサーバーです。元 Adobe Systems の社員がスピンアウトして立ち上げたこともあり、Adobe Media Server との互換性が高く、ほぼ同等の機能を持っています。それにも関わらずライセンス価格は Adobe Media Server と比較するとかなり安価なこともあり、AbemaTV でも生放送のストリーミングサーバーには Wowza Media Server を使用しています。

Red5

Red5

Java で実装されたオープンソースの RTMP プロトコルをサポートするメディアストリーミングサーバーです。Adobe Media Server にかなり似せて作られていて、単体のサーバーとしては同等の機能を提供してくれますが、クラスタリング構成にあまり対応していないため、大規模な配信サービスを構築する場合には注意が必要です。

RTMP を使用するためのクライアントサイド

Web ブラウザは RTMP をネイティブでは対応していません。ブラウザ上で RTMP を使用するためには、プラグインとして Adobe Flash Player を使用する必要があります。

簡単な RTMP ストリーミング配信を実装してみる

まずは RTMP ストリーミングサーバーを構築します。先述したメディアサーバーを使用したいところですが、今回は単純なストリーミング機能のみを提供できれば良いので、NGINX をメディアストリーミングサーバーとして使うことができる nginx-rtmp-module を使います。

Docker で NGINX を立てる

nginx-rtmp-module を追加してコンパイルした NGINX の Docker イメージを作ります。ベースイメージには Alpine Linux を使います。ここでは RTMP 用に 1935 ポートと Flash アプリケーションを読み込むための HTML を返すために HTTP 80 ポートを開けるようにします。

Dockerfile を作成する

下記の Dockerfile では、コンパイルに必要なパッケージを apk でインストールして、任意のバージョンの NGINX と nginx-rtmp-module の Tarball をダウンロードし、コンパイルしています。 ./configure のパラメータがやたら多いですが、必要ないモジュールを除外しているだけです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
FROM alpine:3.4
ENV NGINX_VERSION nginx-1.11.4
ENV NGINX_RTMP_MODULE_VERSION 1.1.7.10
ENV USER nginx
RUN adduser -s /sbin/nologin -D -H ${USER}
RUN apk --update --no-cache \
add ca-certificates \
build-base \
openssl \
openssl-dev \
pcre-dev \
&& \
update-ca-certificates && \
rm -rf /var/cache/apk/*
RUN mkdir -p /tmp/build/nginx && \
cd /tmp/build/nginx && \
wget -O ${NGINX_VERSION}.tar.gz https://nginx.org/download/${NGINX_VERSION}.tar.gz && \
tar -zxf ${NGINX_VERSION}.tar.gz
RUN mkdir -p /tmp/build/nginx-rtmp-module && \
cd /tmp/build/nginx-rtmp-module && \
wget -O nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION}.tar.gz https://github.com/sergey-dryabzhinsky/nginx-rtmp-module/archive/v${NGINX_RTMP_MODULE_VERSION}.tar.gz && \
tar -zxf nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION}.tar.gz && \
cd nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION} && \
wget -O - https://raw.githubusercontent.com/gentoo/gentoo/6241ba18ca4a5e043a97ad11cf450c8d27b3079f/www-servers/nginx/files/rtmp-nginx-1.11.0.patch | patch
RUN cd /tmp/build/nginx/${NGINX_VERSION} && \
./configure \
--sbin-path=/usr/local/sbin/nginx \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--pid-path=/var/run/nginx/nginx.pid \
--lock-path=/var/lock/nginx/nginx.lock \
--user=${USER} --group=${USER} \
--http-log-path=/var/log/nginx/access.log \
--http-client-body-temp-path=/tmp/nginx-client-body \
--with-http_ssl_module \
--with-http_gzip_static_module \
--without-http_userid_module \
--without-http_access_module \
--without-http_auth_basic_module \
--without-http_autoindex_module \
--without-http_geo_module \
--without-http_map_module \
--without-http_split_clients_module \
--without-http_referer_module \
--without-http_proxy_module \
--without-http_fastcgi_module \
--without-http_uwsgi_module \
--without-http_scgi_module \
--without-http_memcached_module \
--without-http_limit_conn_module \
--without-http_limit_req_module \
--without-http_empty_gif_module \
--without-http_browser_module \
--without-http_upstream_hash_module \
--without-http_upstream_ip_hash_module \
--without-http_upstream_least_conn_module \
--without-http_upstream_keepalive_module \
--without-http_upstream_zone_module \
--without-http-cache \
--without-mail_pop3_module \
--without-mail_imap_module \
--without-mail_smtp_module \
--without-stream_limit_conn_module \
--without-stream_access_module \
--without-stream_upstream_hash_module \
--without-stream_upstream_least_conn_module \
--without-stream_upstream_zone_module \
--with-threads \
--with-ipv6 \
--add-module=/tmp/build/nginx-rtmp-module/nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION} && \
make -j $(getconf _NPROCESSORS_ONLN) && \
make install && \
mkdir /var/lock/nginx && \
mkdir /tmp/nginx-client-body && \
rm -rf /tmp/build
RUN apk del build-base openssl-dev && \
rm -rf /var/cache/apk/*
RUN ln -sf /dev/stdout /var/log/nginx/access.log && \
ln -sf /dev/stderr /var/log/nginx/error.log
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY build /var/www/build
RUN chmod 444 /etc/nginx/nginx.conf && \
chown ${USER}:${USER} /var/log/nginx /var/run/nginx /var/lock/nginx /tmp/nginx-client-body && \
chmod -R 770 /var/log/nginx /var/run/nginx /var/lock/nginx /tmp/nginx-client-body
EXPOSE 80
EXPOSE 1935
CMD ["nginx"]

NGINX の configuration を設定する

イメージにコピーする nginx.conf は下記のように設定します。 http コンテキストに加えて nginx-rtmp-module で使用できるようになった rtmp コンテキストに設定を追加しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
user nginx;
worker_processes 1;
daemon off;
events {
...
}
http {
...
}
rtmp {
server {
listen 1935;
listen [::]:1935 ipv6only=on;
application live {
live on;
record off;
}
}
}

この rtmp コンテキストの設定により、ローカルに Docker コンテナを立ち上げたとき rtmp://localhost:1935/live という URL で RTMP サーバに接続が可能になります。

RTMP プレイヤーを実装する

次に RTMP プレイヤーとそれを表示する HTML を作成します。RTMP プレイヤーは Flash アプリケーションとして実装するので ActionScript で書きます。まず新規ファイル Player.as を作成します。

1
$ touch Player.as

Player.asPlayer クラスを作成します。動画を表示するための Video オブジェクトも追加したいので、 Sprite クラスを継承しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package {
import flash.display.Sprite;
import flash.display.StageScaleMode;
import flash.display.StageAlign;
import flash.events.Event;
import flash.events.NetStatusEvent;
import flash.net.NetConnection;
import flash.net.NetStream;
import flash.media.Video;
import flash.external.ExternalInterface;
[SWF(backgroundColor="0x000000")]
public class Player extends Sprite {
private var nc: NetConnection;
private var ns: NetStream;
private var video: Video;
function Player() {
}
}
}

次に Stage の設定します。Flash コンテンツを左上に整列する設定だけします。

1
2
3
4
5
6
7
8
function Player() {
setupStage();
}
private function setupStage(): void {
stage.scaleMode = StageScaleMode.NO_SCALE;
stage.align = StageAlign.TOP_LEFT;
}

NetConnection クラスを使い、クライアントとサーバー間の双方向の接続を作成するための準備をします。 NetConnection オブジェクトのステータスが変化したタイミングで、メディアサーバーからのデータを再生できるように、 NetStream クラスを使ってストリームチャネルを開きます。開いた後ライブストリームを再生するために ns.play() メソッドを実行します。このときストリーム名として "test" を渡していますが、これは後程ライブストリームを作成する際にも使う名前になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Player() {
...
setupNetConnection();
}
private function setupNetConnection(): void {
nc = new NetConnection();
nc.addEventListener(NetStatusEvent.NET_STATUS, onChangeNCStatus);
}
private function onChangeNCStatus(e: NetStatusEvent): void {
const code: String = e.info.code;
if (code === "NetConnection.Connect.Success") {
setupNetStream();
ns.play("test");
}
}

作成したストリームチャネルからの動画を表示するために Video オブジェクトに取り付けます。

1
2
3
4
5
6
private function setupNetStream(): void {
ns = new NetStream(nc);
ns.addEventListener(NetStatusEvent.NET_STATUS, onChangeNSStatus);
video.attachNetStream(ns);
}

この Video オブジェクトもコンストラクト時に作成し、ステージに追加してきます。

1
2
3
4
5
6
7
8
9
function Player() {
...
ssetupVideo();
}
private function setupVideo(): void {
video = new Video(stage.width, stage.height);
addChild(video);
}

NetConnection の準備が整ったので、最後にメディアサーバーに接続します。URL は先程の NGINX の RTMP メディアサーバーの live application に向けています。

1
2
3
4
function Player() {
...
nc.connect("rtmp://localhost:1935/live");
}

ActionScript のコンパイル

これで RTMP サーバーの実装はできたので、次は ActionScript をコンパイルします。コンパイルには Apache/Adobe Flex SDK の Node.js モジュール版である node-flex-sdk を使用します。まずは NPM でインストールします。

1
$ npm i flex-sdk --save-dev

無事インストールできたら、mxmlc というコマンドを使って、 Player.as から Player.swf をコンパイルします。

1
$ $(npm bin)/mxmlc --output=Player.swf Player.as

HTML の作成

作成された Player.swf を表示する HTML を作成します。

1
<object data="./Player.swf" type="application/x-shockwave-flash"></object>

Docker コンテナの起動

Docker コンテナの構成に必要なファイルが揃ったので、これでイメージを作成します。

1
$ docker build -t rtmp .

イメージが作成できたか確認しましょう。

1
2
3
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
rtmp latest ************ About a minute ago 180.7 MB

無事に作成できたら、そのイメージから Docker コンテナを起動します。

1
$ docker run -p 1935:1935 -p 80:80 --name rtmp -t rtmp

Web ブラウザで http://localhost/ にアクセスしてみましょう。

RTMP プレイヤー

黒いボックスが表示されたと思います。このボックスが RTMP プレイヤーなのですが、今は配信するストリームが存在していないため、何も再生できず黒い状態です。ですので、次は再生するストリームを作成します。

ストリームの作成

ここでは、Open Broadcaster Software (OBS)というオープンソースのライブストリーミング用のツールを使用してストリームを作成します。

OBS を起動して、「Settings」ボタンをクリックします。

OBS

「Settings」ダイアログが表示されるので、左側のペインから「Stream」を選択します。 すると「URL」と「Stream key」を入力する画面に切り替わりますので、「URL」に NGINX の RTMP サーバーの live application の URL を入力し、「Stream key」には先程 RTMP プレイヤーを実装したときにストリーム名として指定した test を入力します。

OBS Settings

「Settings」ダイアログで「OK」をクリックしたら、次に「 Sources」の「+」をクリックして適当なメディアソースを追加します。プルダウンメニューが表示されるので「Media Source」を選択して任意の動画ファイルを追加します。

メディアソースが追加されたら、「Start Streaming」ボタンをクリックしてストリーミングを開始します。

OBS Sources

ストリーミングが開始されたら、Web ブラウザに戻ります。すると OBS でストリームしている動画がブラウザの方でも再生されていることが確認できます。

RTMP 再生

RTMP でメディアサーバーのメソッドを呼ぶ

ここまでで RTMP でサーバー側からプッシュされたデータをクライアントで再生する実装をしてきました。しかし、RTMP は双方向のデータ通信が可能なので、クライアント側からサーバー側のメソッドを呼ぶことも可能です。 nginx-rtmp-module では難しいですが、メディアサーバーに Adobe Media Server や Wowza Media Server を利用して開発をした場合、クライアント側からサーバー側のメソッドを呼ぶことが可能です。

ActionScript の場合、クライアントとサーバー間の双方向の接続が作成した後( NetConnection オブジェクトが nc.connect() して、 NetStatusEvent"NetConnection.Connect.Success" になった後)であれば、 nc.call() でサーバー側のメソッドを呼ぶことができます。 nc.call() の第1引数がメソッド名なので、 nc.call("doSomething") のようにクライアントから実行した場合、メディアサーバーに実装した該当のメソッドが実行されます。

NetConnection.call

たとえば Wowza Media Server の場合であれば、実装は Java なので下記のようなメソッドを実装することで、クライアントから Wowza Media Server のコンソールに doSomething is called と表示させることが可能です。

1
2
3
4
public void doSomething(IClient client, RequestFunction function, AMFDataList params) {
getLogger().info("doSomething is called");
// do something
}

AbemaTV の生放送番組では、RTMP の双方向通信を利用して、Web ブラウザから Wowza Media Server のメソッドを呼ぶことで、番組の進行具合に合わせて CM 入りのタイミングや視聴者参加型のインタラクションコンテンツのトリガーを最小限の遅延で放送に挿し込んでいます。

RTMP で受け取った動画を HLS でもストリーミング

メディアサーバーはエンコーダーから RTMP 通信で送られた動画をそのまま RTMP でクライアントにプッシュ送信する以外に HLS や MPEG-DASH でストリーミングできるように変換することも可能です。たとえが nginx-rtmp-module の場合は先程作成した nginx.conf を編集して、 application live コンテキストに HLS に関する以下のディレクティブを追加します。

1
2
3
4
5
6
7
8
9
application live {
live on;
record off;
hls on;
hls_path /usr/local/nginx/html/hls;
hls_fragment 1s;
hls_type live;
}

すると、Live セッションの m3u8 プレイリストと 1 秒感覚のセグメントファイルを /usr/local/nginx/html/hls/live に出力してくれます。この nginx.conf を反映した Docker コンテナが起動している状態で、http://localhost/hls/test.m3u8 に Safari でアクセスすると HLS でストリーミング再生ができます。(Safari でアクセスする理由は前回書いた通り、HLS をネイティブサポートしている Web ブラウザが Safari だけだからです。)

HLS を Safari で再生

まとめ

普段の Web フロントエンドの開発では、RTMP や ActionScript を扱う必要があることはあまりありません。しかし、こと動画やストリーミング領域となるとまだ Flash テクノロジーの安定性にお世話になることも多いように思います。WebRTC や WebSocket などの技術の組み合わせでこのあたりの事情もどんどん変化していきそうです。

参考

フロントエンドエンジニアのための動画ストリーミング技術基礎

AbemaTV という動画サービスをリリースしてから半年経ち、新しくサービスのフロントエンドに関わる人数が少し増えてきたため、動画に関して社内で勉強会を行いました。本記事はその勉強会資料です。

Web でメディアを見るためにはデータのダウンロードが必要

Download via HTTP

Web サービスが HTML を介して提供するコンテンツはテキスト、画像、音声、動画などいろいろありますが、テキスト以外のデータは HTML にインラインで返したりせず、基本的には外部ファイルとして非同期に取得されることがほとんどだと思います。

画像の場合

HTML 内の img 要素の src 属性に表示したい画像ファイルのパスを指定することで、Web ブラウザはその画像をリクエストし、ダウンロードしたデータをデコードして画像として表示します。

1
<img src="sample.jpg" />

https://placekitten.com/g/300/300

動画の場合

動画の場合も同じです。video 要素を使って img 要素と同様に src 属性に動画ファイルのパスを指定します。

1
<video src="sample.mp4"></video>

動画はデータ容量が大きい

画像と違い、動画コンテンツはデータ容量がとても大きいため、データをダウンロードして再生するまでに待ち時間が発生します。

動画のダウンロード

動画のデータ容量が大きい理由はとても単純で、動画は画像データが集合したものだからです。静止画像を人間の目が滑らかに感じられる速さで切り替えて表示することで絵を動かすという表現を実現しています(よくパラパラマンガに例えられますが、そんな感じです)。この人間の目が滑らかに感じる速さというのが 1 秒間に 30 枚だったり 24 枚を切り替えることになります。29.97 (≒30) fps とか 24 fps とかの数字を耳にしたことがあるかと思いますが、24 fps の場合は 1 秒間(s)の間(p)に 24 フレーム(f)を切り替えることを意味します。

データを全て自分の端末にダウンロードしてから再生しようとすると、かなり長い待ち時間が発生してしまいます。もし 2 時間の映画を見ようと思ったら 172,800 (= 24 フレーム * 60 秒 * 60 分 * 2 時間) 枚の画像をダウンロードするのを待つことになります。しかも動画を構成する要素は画像だけではなく、音声データも含まれるため、純粋な情報量としてはそれ以上になります。

ストリーミング

動画データを全てダウンロードしてから再生するのではなく、ダウンロードしたデータで再生できる部分から再生を始め、同時に残りのデータをダウンロードしていく方式を、ストリーミング再生といいます。長時間の動画でもダウンロードしながら再生することができるので、再生するまでの待ち時間を短かくすることができます。

また、ストリーミングでは動画を途中から再生することも可能にします。2 時間映画のたとえば 1 時間経ったあたりから見たいとき、1 時間経過した部分からデータをダウンロードし始め、再生を始めることができます。

シーク

AbemaTV で使用しているストリーミングプロトコル

ストリーミング再生は、映像を配信する側と映像を再生する側で、データをどのような手順で通信するかをあらかじめ決めて、その手順通りに両者がデータを処理することによって実現します。その通信手順のことをストリーミングプロトコルと呼びます。ここでは AbemaTV で使用しているはストリーミングプロトコルを 2 つ説明します。

HTTP Live Streaming

HTTP Live Streaming はアップル社が自社プロダクトである QuickTime、OS X、iOS、Safari 向けに開発したストリーミングプロトコルです。略して HLS と呼ばれるので、この記事でも HLS と表記します。その名前の通り、通信は HTTP で行われます。専用のプロトコルが必要ないため、通常の Web サーバーを用意するだけで配信ができてしまいます。

HLS

HLS を配信するために必要なファイルは、動画を数秒ごとの「MPEG-2 TS」形式のファイルに分割したセグメントファイル、それらをどの順番で再生するかを記したプレイリストだけです。

m3u8 ファイルと ts ファイル

簡単な HLS の配信を試してみる

まず、プレイリストとセグメントファイルを作成します。ここでは ffmpeg というツールを使い、 input.mp4 というファイル名で保存されている動画から output.m3u8 というプレイリストと分割されたセグメントファイルを作成します。

1
2
3
4
5
6
7
8
9
$ ffmpeg -i input.mp4 \
-vcodec libx264 \
-s 1280x720 \
-acodec aac -b:a 256k\
-flags +loop-global_header \
-bsf h264_mp4toannexb \
-f segment -segment_format mpegts \
-segment_time 10 \
-segment_list output.m3u8 output_%04d.ts

すると、 output.m3u8output_****.ts というファイルが作成されます。 output.m3u8 の内容は下記のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:18
#EXTINF:10.500000,
output_0000.ts
#EXTINF:12.625000,
output_0001.ts
#EXTINF:10.416667,
output_0002.ts
#EXTINF:10.416667,
output_0003.ts
...略
output_0058.ts
#EXTINF:5.125000,
output_0059.ts
#EXT-X-ENDLIST

Web サーバーを起動します。ここでは Mac OS X にプリインストールされている Python2 を使用します。

1
$ python -m SimpleHTTPServer

Python2 の SimpleHTTPServer モジュールはデフォルトで 8000 番ポートを使用するので、Safari で http://localhost:8000/output.m3u8 にアクセスします。

Safari で HLS を再生

ここでアクセスする Web ブラウザに Safari を指定しているのは、Safari 以外のメジャーブラウザでは HLS をネイティブサポートしていないためです。Safari 以外のブラウザで HLS を再生するには、Flash などのプラグインを使用するか、後述する Media Source API を使用して、JavaScript で追加実装する必要があります。HLS はアップル社が開発したということもあり、Safari だけは m3u8 をロードしてそのまま再生することができます。

MPEG-DASH

MPEG-DASH は HLS と同様に通信に HTTP を使用したストリーミングプロトコルです。DASH は Dynamic Adaptive Streaming over HTTP の略です。Apple 社が開発した HLS のほかに Microsoft 社が開発した Smooth Streaming や Adobe が開発した HTTP Dynamic Streaming など HTTP ベースのストリーミングプロトコルがいくつかありますが、残念ながら各々互換性がありません。MPEG-DASH は ISO 国際標準規格 (ISO/IEC 23001-6) としてリリースされています。

MPEG-DASH も HLS 同様、通常の Web サーバーと動画のセグメントファイルとプレイリストを用意するだけで配信ができてしまいます。MPEG-DASH ではセグメントファイルは fragmented mp4 もしくは ts 形式、プレイリストは MPD(Media Presentation Description)と呼ばれる XML で記述されたファイルを用意します。

MPD

簡単な MPEG-DASH の配信を試してみる

MPEG-DASH 用のセグメントファイルとプレイリストを用意します。今回はセグメントファイルは fragmented mp4 を使用することにします。まず、ffmpeg を使って動画を fragmented mp4 で映像の圧縮に使う「H.264/AVC」と音声の圧縮に使う「AAC」というコーデックでリエンコードします。コーデックについては後述します。

1
2
3
4
5
6
7
8
9
10
11
12
$ ffmpeg -i ./input.mp4 \
-vcodec libx264 \
-vb 500k \
-r 30 \
-x264opts no-scenecut \
-g 15 \
-acodec aac \
-ac 2 \
-ab 128k \
-frag_duration 5000000 \
-movflags frag_keyframe+empty_moov \
./encoded.mp4

次に MP4Box というツールを使って、動画を分割してセグメントファイルとプレイリストを作成します。

1
2
3
4
5
6
$ MP4Box -frag 4000 \
-dash 4000 \
-rap \
-segment-name sample \
-out ./output.mp4 \
./encoded.mp4

プレイリスト output.mpdoutput.m4s と連番になったセグメントファイル郡が作成されます。 output.mpd は下のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.6.1-revrelease at 2016-09-29T12:57:43.136Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H9M56.466S" maxSegmentDuration="PT0H0M4.000S" profiles="urn:mpeg:dash:profile:full:2011">
<ProgramInformation moreInformationURL="http://gpac.sourceforge.net">
<Title>./output.mpd generated by GPAC</Title>
</ProgramInformation>
<Period duration="PT0H9M56.466S">
<AdaptationSet segmentAlignment="true" maxWidth="320" maxHeight="180" maxFrameRate="30" par="16:9" lang="und">
<ContentComponent id="1" contentType="video" />
<ContentComponent id="2" contentType="audio" />
<Representation id="1" mimeType="video/mp4" codecs="avc3.640014,mp4a.40.2" width="320" height="180" frameRate="30" sar="1:1" audioSamplingRate="48000" startWithSAP="1" bandwidth="631708">
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
<SegmentList timescale="1000" duration="4000">
<Initialization sourceURL="outputinit.mp4"/>
<SegmentURL media="output1.m4s"/>
<SegmentURL media="output2.m4s"/>
<SegmentURL media="output3.m4s"/>
...略
<SegmentURL media="output149.m4s"/>
<SegmentURL media="output150.m4s"/>
</SegmentList>
</Representation>
</AdaptationSet>
</Period>
</MPD>

再び Web サーバーを起動します。

1
$ python -m SimpleHTTPServer

HLS とは違い、残念ながら MPEG-DASH をネイティブでサポートしている Web ブラウザはありません。後程 Media Source Extensions を説明するときに MPEG-DASH プレイヤーを作成するので、そこで確認したいと思います。

HTML5 で扱うストリーミング

HLS と MPEG-DASH は HTML5 用の JavaScript API である Media Source Extensions を利用することで追加でプラグインをインストールすることなく、ストリーミング再生が可能です。

Media Source Extensions

Media Source Extensions は MSE と呼ばれていますので、本記事でも MSE と表記します。MSE は W3C によって標準化されている HTTP ダウンロードを利用してストリーミング再生するために作られた JavaScript API です。

MSE で扱うメディアデータは、W3C で定められている仕様に従って、短い時間で区切ったデータ構造にセグメント化されている必要があります。MSE では、セグメントを 2 種類に分けて扱います。

  • 初期化に必要なヘッダ情報である初期化セグメント
  • 短い時間で区切られたメディアデータ本体が含まれるメディアセグメント

MSE は最初に初期化セグメント、その後にメディアセグメントを順番にソース・バッファに渡すと、そのメディアセグメントの順番で再生していきます。

MSE で簡単な MPEG-DASH プレイヤーを作成してみる

ここでは XMLHttpRequestMediaSource API を使用して簡単な MPEG-DASH プレイヤーを作成して、先程 ffmpeg と MP4Box で作った MPEG-DASH コンテンツを再生してみます。

最初に id をつけた video 要素を用意します。

1
<video id="video"></video>

次に XMLHttpRequest で MPD を取得します。MPD は XML ファイルなので、パースして Representation 要素から MIME タイプやコーデックの情報を取得しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var type, mpd;
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:8000/output.mpd", true);
xhr.responseType = "document";
xhr.overrideMimeType("text/xml");
xhr.onload = e => {
const mpd = xhr.responseXML;
const representation = mpd.getElementsByTagName("Representation")[0];
const mimeType = representation.getAttribute("mimeType");
const codecs = representation.getAttribute("codecs");
type = `${mimeType}; codecs="${codecs}"`
mpd = mpd;
initializeVideo(); // 次の関数へ
};
xhr.send(null);

次に MediaSource API で最初に用意した video 要素を拡張し、ソースとしてダウンロードした動画のセグメントを追加できるようにします。

1
2
3
4
5
6
7
8
9
var mediaSource;
function initializeVideo() {
mediaSource = new MediaSource();
const video = document.getElementById("video");
mediaSource.addEventListener("sourceopen", initializeSourceBuffer, false); // mediaSource が開いたらソース・バッファを作成する
video.src = URL.createObjectURL(mediaSource);
}

ソース・バッファを作成し、初期化情報が入ったセグメントとメディア本体のセグメントを追加できるように準備します。

1
2
3
4
5
6
7
var sourceBuffer;
function initializeSourceBuffer() {
sourceBuffer = mediaSource.addSourceBuffer(this.type);
sourceBuffer.addEventListener("updateend", appendMediaSegment, false);
appendInitializationSegment(); // 次の関数へ
}

先に取得した mpd から Initialization 要素の sourceURL の値を取得し、 XMLHttpRequest で取得します。セグメントファイルはバイナリデータなので、 responseTypearraybuffer に指定しておきます。

1
2
3
4
5
6
7
8
function appendInitializationSegment() {
const xhr = new XMLHttpRequest();
const url = mpd.getElementsByTagName("Initialization")[0].getAttribute("sourceURL");
xhr.open("GET", `http://localhost:8000/media/${url}`, true);
xhr.responseType = "arraybuffer";
xhr.onload = appendSegment;
xhr.send(null);
}

そしてセグメントをロードしたタイミングでソース・バッファに追加します。

1
2
3
function appendSegment(e) {
sourceBuffer.appendBuffer(e.target.response);
}

初期化情報がバッファに追加されソースが更新されたら、続けてメディア本体のセグメントファイルを取得し、ソース・バッファに追加します。この処理をメディアセグメントの数だけ繰り返します。

1
2
3
4
5
6
7
8
9
10
var segmentIndex = 0;
function appendMediaSegment() {
const xhr = new XMLHttpRequest();
const url = mpd.getElementsByTagName("SegmentURL")[segmentIndex++].getAttribute("media");
xhr.open("GET", `http://localhost:8000/media/${url}`, true);
xhr.responseType = "arraybuffer";
xhr.onload = appendSegment;
xhr.send(null);
}

メディアセグメント - 動画とは何か

HLS や MPEG-DASH などのストリーミング配信では、セグメントファイルが実際の動画データになります。本記事の最初に書いた通り、動画データの容量は大きいです。ストリーミング配信の仕組みだけでは、動画の再生開始までの待ち時間は短かくすることはできても、再生を続けるために必要な1秒あたりのデータ量は減らすことはできません。ストリーミング再生では 1 秒あたりに必要なデータ量を少なくとも 1 秒以内に取得し続ける必要があります。でないと再生を継続できません。

同じ情報量を表現するデータの容量を小さくしたい場合、データに圧縮処理をかけます。圧縮のアルゴリズムはいくつもありますが、動画は映像と音声で構成されているため、映像の圧縮に適したアルゴリズムと音声の圧縮に適したアルゴリズムは異なることを考慮する必要があります。映像圧縮に適したアルゴリズムで処理した映像ファイルと音声圧縮に適したアルゴリズムで処理した音声ファイルを1つのファイルとしてまとめたものが動画ファイルです。

コンテナとコーデック

動画ファイルは映像ファイルと音声ファイルをまとめたものと説明しましたが、このまとめ方の形式のことをコンテナフォーマットといいます。また、映像データや音声データを圧縮するアルゴリズムのことをコーデックといいます。

コンテナとコーデック

コンテナ

コンテナフォーマットは一般的にコンテナと略します。コンテナと呼ぶと難しそうですが、コンテナはファイルフォーマットの1種なので、私たちが普段動画ファイルとして意識している単位と一致します。ファイルフォーマットとはファイルの保存形式のことです。以下にリストしたものが代表的なコンテナですが、聞いたことがある名前が多いと思います。

  • AVI
  • MP4
  • MOV
  • MPEG
  • MKV
  • WMV
  • FLV
  • ASF

コンテナは映像と音声データがどのように格納されるのかを定義しています。また動画は映像と音声を同時に再生する必要があるため、両者の同期を取るための情報もコンテナが格納しています。ほかにも動画タイトルや説明などのメタ情報、字幕などの情報もコンテナが格納されている場合があります。

コンテナは対応しているコーデックの映像と音声データのみ格納することができます。1つのコンテナがいくつかのコーデックに対応している場合も多々あるので、コンテナの種類が分かっても格納されているコーデックの種類は分かりません。そのため、動画プレイヤーが同じコンテナで保存された2つの動画ファイルのうち、片方だけ再生できるということもあります。

たとえば Flash 動画のコンテナである FLV は映像コーデックとして「Sorenson Spark」と「H.264/AVC」を格納できます。もし動画プレイヤーが「Sorenson Spark」には対応していても 「H.264/AVC」には対応していなかった場合、「Sorenson Spark」を格納している FLV ファイルは再生できても、「H.264/AVC」を格納している FLV ファイルはコーデックエラーが発生して再生できません。

コーデック

映像や音声は圧縮する必要があります。特にストリーミング再生などのデータ通信と再生を同時に行うような場合は必須です。コーデックはその圧縮のアルゴリズムです。

なぜ映像を圧縮する必要があると言うと、映像はたくさんの静止画をパラパラマンガのようにめくって人間の目に物体や背景が動いているように見せているので、このたくさんの静止画は情報量として膨大なのです。

映像を構成する画像データはラスタという色のついたピクセルの集合で表現します。1 ピクセルの情報量は 24 bit で表現できます(24bit フルカラーの場合、R -赤- G -緑- B -青- の各色成分につき 256 段階の指定ができるため、1 ピクセルは Math.log2(256 * 256 * 256) = 24 bit の情報量が必要)。

そうすると例えば、フル HD の 1 フレームを構成する 1920 * 1080 ピクセルの情報量は 49,766,400 (= 24 * 1920 * 1080) bit になります。これはまだ 1 フレームなので、24 fps の動画の場合、1 秒間に 1,194,393,600 (= 49,766,400 * 24) bit が必要になります。

これは 1 秒間に 1,194 Mbit のデータを通信を介して取得する必要があるということになります。しかし、例えば受信実効速度が 76.6Mbps と記載されているソフトバンク提供の超高速データ通信サービス SoftBank 4G LTE でデータ通信をした場合でも、 1 秒間に取得できるデータ量は 76.6 Mbit なので、先程の 1,194 Mbit に遠く及びません。

しかし、この 1,194 Mbit の映像データは「H.264/AVC」というコーデックで圧縮した場合、典型的な圧縮率としては 1/100 のデータ量に圧縮することができます。すると 12 Mbit 程度になるので、76.6 Mbps のデータ通信速度でも視聴が可能になります。

この「H.264/AVC」は AbemaTV でも映像コーデックとして使用していますが、映像コーデックにはほかにも以下のような種類があります。

  • H.265
  • VP8
  • VP9
  • MPEG-4
  • WMV9

ここでは映像コーデックしか取り上げませんが、音声コーデックは代表的なものに「AAC」や「MP3」があり、AbemaTV では「AAC」を使用しています。

コーデックはデータ量を圧縮するものですが、ただデータ量を減らせればいいのではなく、人間が知覚できる範囲の画質や音質を落とすことなく圧縮しなくてはいけません。なので、選択するコーデックが悪いと画質や音質を落とすことになります。

AbemaTV で使用しているコンテナ MPEG-2 TS

「MPEG-2 TS」は MPEG-2 システムのうち放送・通信用のコンテナです。地上波デジタル放送でも使用されているコンテナですが、HLS でも「MPEG-2 TS」を使用します。DevTools の Network パネルを開いた状態で AbemaTV の動画を視聴しているとたくさんの **.ts という拡張子のデータがリクエストされるのが確認できます。これが「MPEG-2 TS」のファイルです。

「MPEG-2 TS」は放送・通信用に作られたコンテナのため、通信途中でデータが途切れたとしてもちゃんと再生できるように設計されています。「MPEG-2 TS」では動画を 184 バイト単位のデータに分割し、それに 4 バイトの TS ヘッダと呼ばれるデータを付加して計 188 バイト固定長のパケットを連続で転送することでデータ伝送を行います。4 バイトの TS ヘッダのうち最後の 4bit は巡回カウンターと呼ばれるデータを持っていて、これがパケットごとに 1 ずつカウンターするため、これを検査することでパケットの欠落がないかを確認できるようになっています。

TS パケット

MPEG-2 システムには蓄積メディア用のコンテナとして別に「MPEG-2 PS」がありますが、こちらはデータが連続していることが前提なので、ランダムアクセスなどに優れた設計になっています。

AbemaTV で使っている映像コーデック H.264/AVC

AbemaTV では「MPEG-2 TS」コンテナに「H.264/AVC」コーデックで圧縮した映像データを格納しています。「H.264/AVC」は正式名称を「H.264」もしくは「MPEG-4 Part 10 Advanced Video Coding」といいます。(正式名称が2つあるのは ITU-T と ISO/IEC という2つの組織が共同で策定したものをそれぞれの名称をつけているだけです。)「MPEG-4」という名前が付けられている通り、その圧縮アルゴリズムの原理は、従来方式の「MPEG-1」、「MPEG-2」を継承しています。ここでは「MPEG」の圧縮アルゴリズムの原理を学んでいきます。

圧縮の基本

データを圧縮する基本は

  • 出現するデータパターンに偏りを持たせること
  • 出現頻度が高いパターンを短く表現すること

です。単純な例で見ていきます。

出現頻度が高いパターンを短く表現する

たとえば、文字 a-d があったとき、それらを識別する符号を下記のように表現できます。

文字 符号
a 00
b 01
c 10
d 11

文字列「bbabcbdbaacba」は「01 01 00 01 10 01 11 01 00 00 10 01 00」という符号で表現されます。この文字列を表現するのに必要なデータ量は 26(=2*13)bit です。この文字列にて、各々の文字の出現回数は均一ではありません。

文字 出現回数 出現率
a 4 0.31
b 7 0.54
c 2 0.15
d 1 0.08

そこで出現回数が 1 番多い b に 1 番短い符号、2 番目に多い a に次に短い符号を割り当ててみます。

文字 符号
a 10
b 0
c 110
d 111

すると先程の文字列「bbabcbdbaacba」は「0 0 10 0 110 0 111 0 10 10 110 0 10」と表現されますが、データ量が 23bit に減りました。このように、データの出現頻度が均一ではなく偏りがあると、異なる長さの符号を割り当てることによりデータ量を圧縮することができます。

このように可変長の符号を出現頻度に応じて割り当てることエントロピー符号といいますが、その割り当てパターンを作成する方法の 1 つにハフマン符号があります。

ハフマン符号

この図のように出現確立が高いものからツリー上に符号を割り当てていきます。これにて全ての文字が一意かつ瞬時に解読できる少ないデータ量の符号を作成することができます。

出現するデータパターンに偏りを持たせる

一見出現率に偏りがない場合でも情報の表現方法を変えることでデータの出現頻度に偏りを持たせることができます。

たとえば、「1 2 3 2 1 0 -1 -2」のような数列はそのままだと下記のような出現回数ですが、

数字 出現回数 出現率
-2 1 0.125
-1 1 0.125
0 1 0.125
1 2 0.25
2 2 0.25
3 1 0.125

これを前の数字との差分として表現すると「1 2 3 2 1 0 -1 -2」→「0 +1 +1 -1 -1 -1 -1 -1」となり、データの出現頻度に大きな偏りを作ることができました。

差分 出現回数 出現率
0 1 0.125
+1 2 0.25
-1 5 0.625

これをハフマン符号することでデータを圧縮することができます。

MPEG の圧縮

文字列や数列データの圧縮の例について見てきましたが、動画圧縮の場合も基本的な考え方は同様です。しかし、MPEG の場合は動画特有の性質を利用して圧縮率を高める工夫をしています。

MPEG の圧縮アルゴリズムは静止画の圧縮と映像の圧縮で構成されています。

  • 静止画自体のデータサイズを圧縮する
  • 連続する映像フレームのデータの差分だけを記録する

静止画の圧縮

MPEG の静止画の圧縮アルゴリズムの基礎は画像圧縮規格である「JPEG」です。**.jpg の拡張子で馴染のアレです。

静止画の圧縮アルゴリズムは簡単に以下のようなことを行います。

  • 画像は隣り合うピクセルが似ているという特徴を利用して差分情報だけで表現する
  • 人間の目が変化に鈍感な情報を省略する
  • エントロピー符号する

画像は隣り合うピクセルが似ている

たとえば空の写真を撮影した場合、その画像を構成するピクセルの多くは空の青色と雲の白色の微妙な色味の変化になると思います。空ほど色数が少なくない写真や絵の場合でも、基本的に画像は色が段階的にしか変化していないピクセルの方が出現頻度が圧倒的に多く、急な変化の頻度は少ないはずです。

空

この画像の性質を利用して、画像データの出現頻度に偏りを作って符号化することを DPCM 符号化といいます。

人間の目が変化に鈍感な情報を省略する

MPEG は離散コサイン変換という演算を行うことで、人間の目にあまり目立たない細かい情報をデータから取り除いてしまうことで圧縮率を上げています。離散コサイン変換は英語では Discrete Cosine Transform というので DCT と略されます。

DCT では画像を波形として扱い、フーリエ変換のように周波数ごとの波の強度で画像を表現します。ここで高い周波数の波は人間の目にあまり目立たない情報となるので、省略してしまうことで画質への影響を最小限に抑えながら圧縮率を高めることが可能になります。

DCT

エントロピー符号

ここまで静止画の圧縮について、DPCM 符号化と DCT の処理を見てきましたが、これらで求められた値をエントロピー符号することで更に圧縮効率を高めます。

映像の圧縮

静止画の圧縮では、映像における 1 枚 1 枚のフレームのデータ量を削減しました。映像の圧縮では、時間の流れを利用してデータの圧縮率を高める工夫をしています。

画素の省略

MPEG では画素情報を RGB ではなく、輝度信号(Y)色差信号(Cr)(Cb) で表現します。RGBの各成分、輝度信号(Y)、色差信号(Cr)(Cb)の関係は下記です。

1
2
3
Y = 0.299*R + 0.587*G + 0.114*B
Cr = 0.500*R - 0.419*G - 0.081*B
Cb = -0.169*R - 0.332*G + 0.500*B

人間の目は明るさの変化に対しての方が色の変化に対してより敏感です。MPEG ではその人間の視覚の癖を利用し、フレームごとに Cr と Cb 信号を画素の情報から省いています。Cr と Cb が少々省かれたとしても 明るさの情報である Y が省かれていなければ、人はそれ程違和感を感じないのです。

CrCb の省略

フレーム間予測

静止画の場合と似ていますが、映像の場合も時間的に隣合うフレームが持つ画像は似ているはずです。MPEG はその映像の特徴を利用して、映像のフレームにその画像を表示するための全ての情報を持たせません。MPEG には 3 種類のフレームがあります。

  • I ピクチャ
  • P ピクチャ
  • B ピクチャ

I ピクチャを除いて、他のフレームが持ってる情報と自身が持ってる情報を合わせて画像を表示することができるようになります。この 3 種類はそれぞれ役割りが違います。I ピクチャは画像を表示するための全ての情報を持っています。P ピクチャは過去に表示したI ピクチャもしくはP ピクチャが持っていたデータとその差分データを使用して画像を表示します。B ピクチャは過去だけではなく未来のI ピクチャもしくはP ピクチャが持っているデータを利用することでより圧縮率を高めます。

I/P/B ピクチャ

まとめ

動画は昔からある技術分野ですが、Web のフロントエンドエンジニアだった自分には足を踏み込んだら分からないことだらけの難しい分野だと感じました。しかし、最近はストリーミング関連の技術も進み、Web においても動画を扱った事業に関わることが増えてきています。本記事は社内勉強会向けですが、フロントエンドエンジニア視点から動画を学んでいくスタートポイントになればと思います。

参考

HTTP Live Streaming

https://en.wikipedia.org/wiki/HTTP_Live_Streaming

H.264

https://ja.wikipedia.org/wiki/H.264

MPEG-2システム

https://ja.wikipedia.org/wiki/MPEG-2%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0

HLSとは

http://qiita.com/STomohiko/items/eb223a9cb6325d7d42d9

ffmpeg で mp4 をiPhone用のストリーミング(HLS)に対応させる。

http://takuya-1st.hatenablog.jp/entry/2016/04/06/034906

MPEG DASHを知る

http://qiita.com/gabby-gred/items/c1a3dbe026f83dd7e1ff

MPEG-DASH content generation with MP4Box and x264

https://bitmovin.com/mp4box-dash-content-generation-x264/

Media Source Extensionsを使ってみた (MP4編) http://qiita.com/tomoyukilabs/items/54bd151aba7d3edf8946

動画・音声の規格について ~コーデック・コンテナ~

http://michisugara.jp/archives/2011/video_and_audio.html

VIDEO-ITを取り巻く市場と技術

http://www.mpeg.co.jp/libraries/video_it/index.html

動画形式の種類と違い(AVI・MP4・MOV・MPEG・MKV・WMV・FLV・ASF等)【コンテナ】

http://aviutl.info/dougakeisiki-konntena/

【動画が再生できない!?】そんなときに必ず役立つ5つの知識

http://smarvee.com/column/can-not-play/

「映像がH.264/AVCでエンコードされたFLV」を「FLV5」と呼ぶのは間違い

http://goldenhige.cocolog-nifty.com/blog/2009/10/h264avcflvflv5-.html

量子化行列のナゾ~その1

http://www.nnet.ne.jp/~hi6/lab/quantize/

モニタ解像度 図解チャート&一覧 / monitor resolution data sheet&chart

http://www.quel.jp/etc/monitor-size/

フロントエンド視点:デザイナーと協業して作るスタイルガイドの難易度を下げる

前回 Atomic Design を実案件に導入した話で触れたコンポーネントリストを作り始めたとき、個人的にいろいろと学びがありました。

コンポーネントリストやスタイルガイドは、フロントエンドエンジニアとデザイナーが協業して作る必要がありますが、この協業がなかなかうまくいかず、スタイルガイド運用をワークさせるのは難易度が高い印象がありました。

自分自身、今まで携ってきた開発プロジェクトにおいて、うまくワークしたと思えるスタイルガイド作りができなかったのですが、現在のプロジェクトで開発初期からスタイルガイドを作り、リリース後、運用フェーズまでワークさせることができました。

本記事ではフロントエンドエンジニアとデザイナーが協業してスタイルガイドを作り始めるにあたって気をつけたことについて書いていきます。

共通言語としてのスタイルガイド

開発において、スタイルガイドが欲しい理由は、開発中のコミュニケーションにおいて、デザインを言語化できないことが多いからです。スタイルガイドの役割はデザイナー以外の職種にも通じる(デザイン要素に関する)共通言語として働くことです。そしてたぶんメインデザイナーとそれ以外のデザイナー間での共通言語としても働いてくれると思います。

1つの作業を2者で行う場合、2者の間に共通言語がないと、コミュニケーションコストは一気に高くなります。今の職場では、基本的にデザインとフロントエンドエンジニアリングは分業しているので、デザイナーがエンジニアリングを理解するか、エンジニアがデザインを理解するか、いずれかの状況でない限り(もしくはその状況であったとしても)、コミュニケーションコストは高いです。それを下げる意味でスタイルガイドを作ることは開発において大きな意味を持ちます。

スタイルガイドは死にやすい

しかし、たとえ本格的に開発が始まる前にスタイルガイドを作ったとしても、スタイルガイドがプロダクトのデザインで使えるものになっていなければ、いずれ誰からも参照されない状態になってしまいます。

過去に別のサービスを作っていた際、開発速度を上げるため最初にスタイルガイド/コンポーネントリストを定義し、 それを PSD ファイルで管理するようにしました。以降、画面デザインはそのコンポーネントを使って行う手法を試みました。しかし、残念ながらそのコンポーネントをそのまま使うことは少なかったです。

デザイナーがプロダクトの画面デザインを先に行うことなく、プロダクトの画面上で実際に起こる問題に対して、デザイン的な解決を汎用的なコンポーネントに落とし込むことができなかったからです。実際に画面上に定義したコンポーネントを置いたときに、解決すべき問題が解決できないことが頻発しました。

問題の解決を行うに足りるコンポーネントがないと、新しいコンポーネントを別途作るしかないので、コンポーネントは延々と増え続け、管理できなくなり、そのサービスをリリースする頃には誰もスタイルガイドを見ることはなくなっていました。

予想以上に開発初期にスタイルガイドを作ることは難しい

タイトなスケジュールでスタイルガイドを見直す時間もなかったのと、見直すこと自体を開発フローに組み込んでいなかったため、スタイルガイドは作っただけで意味のないものになってしまいました。

レギュレーションをしっかり決めた方が今後のデザインにブレがなくなるだろうという想いもあり、最初からスタイルガイドにいろいろと定義をつめこみすぎたために(そしてその定義が完璧とは程遠いため)、デザイナーも窮屈になってしまったのだと思います。デザイナーが早々にスタイルガイドを参照することをやめてしまっていました。

自分たちが思っていた以上にスタイルガイドを最初から完璧に作ることは難しかったのです。

エンジニア主導だったスタイルガイド作り

そしてエンジニア主導でスタイルガイドを作った点もスタイルガイドが死んでいった要因だったように思います。そのときのデザイナーはスタイルガイドを作ることにあまり利点を感じていなかったように思います。今思えば、自分たちエンジニアも、デザイナーにスタイルガイドの利点を伝えきれていなかったと思うので、当然かもしれません。スタイルガイドがある開発フローに対して成功体験がなかったので、伝えきれるわけもないとも思います。

特に開発初期は、エンジニアが主導になってスタイルガイドを完璧に作ることはほぼ無謀に近いでしょう。これから実際にデザインを考えていくのはデザイナーです。しかし、スタイルガイドを作るというモチベーションはエンジニア側にあることが(少なくとも自分の周りでは)多いように感じます。

スタイルガイドを作るモチベーションがデザイナーよりエンジニア側にある場合、エンジニアは主導になるのではなく、ファシリテーターとして動くように意識すべきだったなと思います。

もろもろの後悔もあって、今のプロジェクトで開発を開始した時、なんとかリリースまで生き続けるスタイルガイドを作ろうと思いました。とりあえずちょっとした成功体験があるだけでも今後の視野が変わりそうだなと。

デザイナー以外でもデザインを判断できる状況を目指す

フロントエンドエンジニアの視点で言うと、デザインに関して、デザイナーしか判断できない事柄が多すぎるのは大きなツラミです。デザイナーしか判断できない状況は、Photoshop や Sketch のデータから読み取れない事柄において、全てデザイナーに判断を仰ぐ必要がある状態です。それはとてつもなくコミュニケーションコストが高いのです。コミュニケーションコストが高いからと言って、エンジニアが勝手に解釈して実装してしまえば、デザイナーに意図に沿わず、結局実装後に修正することになり、更に工数が膨れます。

コミュニケーションコストを下げるためにも、デザイナーの頭の中がプロジェクト全体に共有され、簡単なことであれば徐々にエンジニアでもデザインに関する判断を下せるようになっていけるのが理想です。生きたスタイルガイドがあれば、デザイン要素に関する最低限の判断は誰でもできる状態にすることができます。仕組みによって、判断しなくて良い状況を増やすからです。

そしてスタイルガイドを作る過程では、デザインを論理的な言葉に置き替えていく必要もあるため、その作業をエンジニアが一緒に行うことでデザイナーがビジュアルを通じてユーザーに伝えようとしている意図を言葉で理解する手助けになります。

箱だけスタイルガイドを作る

過去の後悔を踏まえつつ、デザイナーとエンジニアが無理なく協業できる方法を工夫しようと考えました。特にデザイナーに負担が少ないように、できるだけ最初は既存のデザインフローを変えないように心掛けました。

  • スタイルガイドは最初から定義しすぎないようにする
  • 作りながら定義を追加していく

開発開始時はスタイルガイドには何も定義がされていない状態にしました。ただ、これから定義していく項目だけ決めておくことにしました。中身がない箱だけ用意したイメージです。決める項目というのは、一般的な Web アプリケーションに最低限必要そうな要素です。

  • アプリケーションの基本的な背景色や文字色、ボタン色などのカラーコードを今後決める
  • 要素感の余白のサイズやフォントサイズ、ボーダーの幅、角丸の大きさを今後決める
  • UIアニメーションの長さやイージングを今後決める

などです。今後決める、となっているのは、背景色やフォントサイズなどの値はこの時点で決めないからです。この時点では今後決める項目だけを決めます。

このタイミングで挙げた項目のほかにも必要になる項目は出てくるとは思いますが、必要になったときに追加すれば良いと割り切りました。定義する項目の粒度がパターンとして分かる程度にしておきます。こうしておくことで後から追加する項目の粒度も揃いやすくなります。

スタイルガイドのたたきを Sketch で作る

上記の項目を箱として用意し、実際の値としては定義していない状態で、スタイルガイドを Sketch データにします。

スタイルガイド Sketch データ

上の画像で NO SET となっているところが実際の値が定義されていない項目です。この時点で決まっている値があれば定義し、決まっていない、分からない値については無理に決めず、 NO SET としておきます。

そして、Sketch で設定した値を CSS のカスタムプロパティとして設定します。(今回のプロジェクトでは cssnext を使用しているため、カスタムプロパティとして設定しました。Sass や Stylus などでも変数で設定しても良いかもしれません。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
:root {
/* background */
--bg-regular: #000000;
--bg-highlight: #f0163a;
--bg-hover: #191919;
--bg-active: none;
/* background - light */
--lt-bg-regular: #ffffff;
--lt-bg-highlight: #f0163a;
--lt-bg-hover: #fafafa;
--lt-bg-active: #fafafa;
/* font size */
--font-size-xxl: 22px;
--font-size-xl: 18px;
--font-size-l: 16px;
--font-size-m: 14px;
--font-size-s: 13px;
--font-size-xs: 12px;
--font-size-xxs: 11px;
/* font color */
--font-color-regular: #eeeeee;
--font-color-success: none;
--font-color-danger: #f0163a;
--font-color-warning: #f0163a;
--font-color-info: #8c8c8c;
--font-color-link: #6fb900;
--font-color-link-hover: #c5c5c5;
--font-color-active: none;
/* font color - light */
--lt-font-color-regular: #1a1a1a;
--lt-font-color-success: none;
--lt-font-color-danger: #f0163a;
--lt-font-color-warning: #f0163a;
--lt-font-color-info: #8c8c8c;
--lt-font-color-link: #6fb900;
--lt-font-color-link-hover: #c5c5c5;
--lt-font-color-active: none;
...
}

上のコードで none となっているところは、Sketch で NO SET としたところと同じ意味です。定義できている値のみ設定して、定義できていないものはプロパティ名だけ用意しておいて none を設定しておきます。

プロダクトの UI コンポーネントにあてる CSS は基本的にこのカスタムプロパティを参照し、特別な理由がない限り、値をハードコードすることを避けるようにします。

ここで、最初に Sketch でスタイルガイドを作るのは、Sketch から始める方がデザイナーが心理的な障壁なく作業に入りやすいためです。最終的にスタイルガイドは HTML で管理する方がプロダクトのコードと同期しやすいので、本当は Sketch を介さず、最初から CSS のカスタムプロパティと HTML でドキュメント化できると、効率は良さそうにも思います。ただ、ステップをファシリテートするという意味では、Sketch や Photoshop などから始める方が良いように個人的には思い、今回のプロジェクトでは Sketch から始めました。

Interface Inventory を実践しながら箱を埋めていく

ここまでで、今後決める項目は決めることができたので、ここからは項目に実際の値を入れていきます。箱を埋めていく作業です。箱を埋める作業は各画面のデザインカンプを作りながら、必要に応じて進めていきます。

前回の投稿で、Interface Inventory をゆるく実践したと書きましたが、このように、デザイナーにはデザインカンプを通常通り作ってもらいながら、カンプ上で使用した色やフォントサイズなどで、定義していない値を使うたびに適切な項目への値として埋めていくようにしてもらいました。

フロントエンドエンジニアもカスタムプロパティを参照して CSS を書くので、定義されていない値がカンプに出現した場合は、デザイナーと話してその値をどの項目として定義するかを決めてから実装することにします。これを繰り返してスタイルガイドに定義する値を徐々に埋めていくことになります。

デザイナーの中には画面上に色などのデザイン要素を配置するとき、感覚で配置されている方もいると思います。そういったデザイナーとの協業だった場合でも、最初に埋めるべき箱がデザイン要素としての意味を持った名前とともに用意されているので、名前の意味と異なるカラーコードが出てきたら、そのカラーコードを見直すきっかけになります。

そして、既存のどの箱にも適切に入らない値が出てきたタイミングにのみ、新しい箱を用意します。

新規の箱は既存の箱と十分と向き合ってから作る

新しい箱に入れる必要がある新規の値がデザインカンプ上に出現した時に意識したいのが、箱を本当に新規で追加する価値があるのかを再度デザイナーと考えてみることです。今回も、既存の箱に入っている値では本当に画面上の問題を解決することができないのかということをデザイナーと時間をかけて一緒に考えました。

箱が増えるということは、プロダクトを通してのトンマナがブレるリスクがあるだけでなく、UI を通して送るユーザーへのメッセージの種類が増えることになります。基本的にユーザーへのメッセージの種類はシンプルな方が良いので、増やさなくて良いのであれば、それに越したことはありません。意図したメッセージが適切に伝わりづらくなるというリスクが増すからです。

箱は適切な抽象度を意識する

それでも新規の箱が必要になった場合、箱の名前は適切な抽象度を保っているかを注意します。新規の箱は、それが必要になったデザインカンプの画面のコンテキストにすごく影響されて命名してしまうことが多いです。例えば、コメント一覧 UI の画面デザインで新規に必要になった背景色のカラーコードに対する箱の名前を --bg-comment と命名するなどです。

「コメントの背景」という名前では画面の UI 上でどんなデザイン的にどんな働きをするのかが分かりません。もちろん、コメント系モジュールの背景はどんな画面にレイアウトしたとしても同じ色で統一するのであれば問題ないかもしれません。その色がコメントを表す特別な色という意味を持っているということであれば、 --bg-comment という名前も適切かもしれませんが、実際は別の画面になればコメントより目立たせたい要素が別にあるなど、コメント系モジュールであっても、別の色が適切な場合は多いはずです。

その場合はデザイン的な働きを適切な抽象度を持った名前で表現することが大事だと思います。その画面上でユーザーに最も注目してもらいたい要素であれば、 --bg-highlight という名前で コメント一覧モジュールの背景を塗るのが適切でしょう。

新規の箱に具体的な名前をつけて別項目として設定しまうことは簡単です。なので、その誘惑に駆られることは何度もありました。しかし、そうやってつけられた名前は適切な抽象度を持たないため、別の画面では使われない可能性が高くなる上に、別の箱を作る要因になるので、あっという間に人が管理できない個数へと箱が増えてしまいます。

適切な抽象度とは、色でいうと、基本色や強調色、警告色といったアプリケーションにおいて普遍な要素名に対してサービスのキャラクターづけがされた値を設定するようにします。これより具体性が高い要素名は、カンプ上に配置されたコンテキストに強く影響されている可能性が高いので、デザインレビュー時に再度名前の見直しをするようにしました。

サービスにおいてコアバリューに近いものに関しては具体性が高い要素名が適切な場合があるかもしれません。先程の例で言うと、コメント機能がサービスのコアバリューでほかの要素とは完全に差別化し、どの画面にいてもコメントが特別だと分かるように色を一色に統一したい、という場合は具体的な名前が適切でしょう。ただそういった名前は、サービス1つにつき多くても1、2個のように思います。

明パターンと暗パターンの箱を用意する

プロダクトの強調色や警告色など、全ての色は2パターン決めておくとよいです。スタイルガイドは背景色を白で用意することが多いと思いますが、もしプロダクトのベースが白であっても、配置するモジュール郡の中には暗い色のものもあると思います。

スタイルガイドで定義したカラーコードが、これら暗い色のモジュールを考慮していないと、明度差が足りなくて視認性や可読性が下がったりして、強調色が強調色として働かない可能性が出てきます。

そこでスタイルガイドには最初から明るいパターンの背景上に乗るもの用と暗いパターンの背景上に乗るもの用を用意しておき、色系の要素には常に2パターンの箱がある状態にしました。

Sketch 明パターンと暗パターン

CSS のカスタムプロパティも2パターン用意します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* font color - dark */
--font-color-regular: #eeeeee;
--font-color-success: none;
--font-color-danger: #f0163a;
--font-color-warning: #f0163a;
--font-color-info: #8c8c8c;
--font-color-link: #6fb900;
--font-color-link-hover: #c5c5c5;
--font-color-active: none;
/* font color - light */
--lt-font-color-regular: #1a1a1a;
--lt-font-color-success: none;
--lt-font-color-danger: #f0163a;
--lt-font-color-warning: #f0163a;
--lt-font-color-info: #8c8c8c;
--lt-font-color-link: #6fb900;
--lt-font-color-link-hover: #c5c5c5;
--lt-font-color-active: none;

最終的にHTML (というか React と JSX)で作ったコンポーネントリストでは、実際に明るい背景と暗い背景の上にコンポーネントを置いて管理しました。これにより明るい背景では使えるけど、暗い背景ではそのまま使えないコンポーネントを一目で確認できるようになります。

React JSX 明パターンと暗パターン

空き箱を用意することで運用の難易度が下がった

空き箱を適切な抽象度を意識して用意することで、スタイルガイドの運用はうんと楽になりましした。以前は自分の中に、スタイルガイドは(ガイドというくらいなので)UI デザインを先導するものであるべきという意識がありました。先にスタイルガイドがあって、デザインがそのガイドに従って作られるべきと。でも、自分たちはある意味未知のものを創っているのに、それを先導するものを作る難易度はとても高いです。今回は作ってみたデザインカンプからガイドとなるパターンを見つけていく仕組みを緩く作ることで、スタイルガイドが以前よりもワークするようにできました。

今回のプロジェクトでスタイルガイドに助けられた部分は大きいです。スタイルガイドがあることでデザイナーとの会話はしやすくなりましたし、今まで Photoshop や Sketch を通してしかできなかった部分のコミュニケーションを大きく補足してくれました。そしてエンジニアがデザインについてすこし深く考えるためのツールにもなったと思います。

そして「こんな感じでスタイルガイド作っていきたいんだけど」って相談したら一晩で Sketch のテンプレート作ってくれた @pekep に感謝。