Все говнобраузеры до сих пор не умеют в RTSP и даже не умеют нормально проигрывать бесконечный поток. Какое-то позорище натуральное. Бесконечный поток с камеры, например, поставленный на паузу вообще ни разу не должен продолжаться с той самой секунды когда его остановили. Внезапно. А должен продолжаться при снятии с паузы с самого последнего принятого keyframe. И браузеру нахер не нужно сжирать всю память пытаясь сохранить всё, что было передано с момента нажатия на паузу. Но, видимо, для браузерописцев эти элементарные вещи совершенно недоступны для понимания, и в результате реализация HTMLVideoElement’а, что в хроме, что в фаерфоксе является полным говном.
Для проигрывания таких потоков вебмакаки используют монстроузные жирнющие жабоскриптные плееры, которые фактически с ложечки кормят HTMLVideoElement загружаемыми кусочками видео, обычно DASH или HLS.
Оказалось что самый маленький плеер умеющий проигрывать multiple-file-DASH видео (openplayer.js) весит 167 килобайт, сцуко! И это минифицированный вариант! Чему там можно столько весить я нихрена не понял. Про «референсный» dash.js я вообще молчу. Это лютейший звездец размером больше полмегабайта в минифицированном виде. Мелкософтовский пример жабоскриптного DASH-плеера заточен на single-file-DASH. А ffmpeg c -single_file 1
пишет поток на диск бесконечно, засирая диск далеко не всегда нужным видеомусором.
Разумеется, как всегда, когда дело касается вебни, чтобы решить элементарную проблему, вместо того, чтобы взять какой-нибудь эталонный микроскопический плеер, пришлось быстренько изобрести велосипед. Разумеется, всё легко влезло в смешные 5 килобайт, причём в неминифицированном виде.
Вот велосипед:
Жабоскрипт:
'use strict'
function Dash( id )
{
var _ = this;
_.v = document.getElementById( id );
var url = _.v.src;
_.v.src = undefined;
var slash = url.lastIndexOf("/");
_.path = url.substring( 0, slash + 1 );
_.mpd = url.substring( slash + 1 );
_.init = true;
_.xhr = [];
_.load_mpd( false );
_.video_update = function() { _._video_update(); };
_.video_updated = function() { _._video_updated(); };
_.video_updating = false;
_.v.addEventListener( 'click', _.toggle.bind(_), false );
_.v.addEventListener( 'pause', function ()
{
console.log( 'paused' );
this.removeEventListener( 'timeupdate', _.video_update );
if( _.videoSource.updating ) _.videoSource.abort();
_.xhr.forEach( function ( xhr ) { xhr.abort(); } );
}, false);
_.v.addEventListener( 'play', function()
{
console.log( 'playing' );
this.addEventListener( 'timeupdate', _.video_update, false );
}, false);
_.v.addEventListener( 'ended', function()
{
console.log( 'ended' );
this.removeEventListener( 'timeupdate', _.video_update );
}, false);
_.v.addEventListener( 'error', function(e) { console.log( 'video error: ' + e ); }, false );
_.v.addEventListener( 'emptied', function() { console.log( 'emptied' ); }, false );
}
Dash.prototype.play = function() { this.load_mpd( true ); }
Dash.prototype.stop = function() { this.v.pause(); }
Dash.prototype.toggle = function()
{
if( this.v.paused == true ) this.play();
else this.stop();
}
Dash.prototype.mpd_parse = function( data )
{
var _ = this, e, g;
e = data.querySelectorAll("Representation")[0];
g = e.getAttribute.bind( e );
_.id = g( 'id' );
_.mimetype = g( 'mimeType' );
_.codecs = g( 'codecs' );
_.width = g( 'width' );
_.height = g( 'height' );
e = data.querySelectorAll( 'SegmentTemplate' )[0];
g = e.getAttribute.bind( e );
_.timescale = g( 'timescale' );
_.ini = g( 'initialization' );
_.seg = g( 'media' );
_.start = g( 'startNumber' );
_.ini = _.ini.replace( '$RepresentationID$', _.id );
_.seg = _.seg.replace( '$RepresentationID$', _.id );
_.duration = [];
data.querySelectorAll("S").forEach( function( v, i )
{
_.duration[i] = v.getAttribute( 'd' ) / _.timescale;
if( i == 0 ) _.ts = v.getAttribute( 't' );
} );
}
Dash.prototype.load_mpd = function( start )
{
var _ = this;
_.xhr[0] = new XMLHttpRequest();
_.xhr[0].onreadystatechange = function ()
{
if( this.readyState != this.DONE ) return;
if( this.status != 200 ) return;
var parser = new DOMParser();
var xmlData = parser.parseFromString( this.response, 'text/xml' );
_.mpd_parse( xmlData );
if( start )
{
_.video_updating = false;
_.num = parseInt( _.start ) + 1;
_.ts_current = _.v.currentTime;
_.load_seg();
}
if( _.init )
{
_.v.width = _.width;
_.v.height = _.height;
_.mediaSource = new window.MediaSource();
_.mediaSource.addEventListener( 'sourceopen', function (e) {
_.videoSource = _.mediaSource.addSourceBuffer( _.mimetype + '; codecs="' + _.codecs + '"' );
_.videoSource.mode = 'sequence';
_.videoSource.addEventListener( 'updateend', _.video_updated, false );
_.load_ini();
}, false );
_.v.src = URL.createObjectURL( _.mediaSource );
}
}
_.xhr[0].open( 'GET', _.path + _.mpd );
_.xhr[0].send();
}
Dash.prototype.append = function( f )
{
var _ = this;
_.xhr[1] = new XMLHttpRequest();
_.xhr[1].responseType = 'arraybuffer';
_.xhr[1].onreadystatechange = function ()
{
if( this.readyState != this.DONE ) return;
if( this.status != 200 ) return;
_.videoSource.appendBuffer( new Uint8Array( this.response ) );
};
_.xhr[1].open( 'GET', _.path + f );
_.xhr[1].send();
}
Dash.prototype.load_ini = function()
{
this.append( this.ini );
}
Dash.prototype.load_seg = function()
{
var n = this.num.toString();
if( n.length < 5 ) n = ( '0000' + n ).slice( -5 );
this.append( this.seg.replace( '$Number%05d$', n ) );
}
Dash.prototype._video_update = function()
{
var _ = this;
if( _.video_updating ) return;
var i = _.num - _.start;
if( _.v.currentTime >= _.ts_current - _.duration[i] * 0.5 )
{
_.video_updating = true;
_.load_seg();
}
}
Dash.prototype._video_updated = function()
{
var _ = this;
if( _.init ) { _.init = false; return; }
if( _.v.paused == true )
{
_.v.currentTime = _.ts_current;
_.v.play();
}
_.ts_current += _.duration[ _.num - _.start ];
_.num++;
if( _.num - _.start >= _.duration.length - 1 ) _.load_mpd( false );
_.video_updating = false;
}
Страничка:
<HTML>
<HEAD>
<META HTTP-EQUIV="Pragma" CONTENT="no-cache">
<META HTTP-EQUIV="Cache-Control" content="no-cache">
<META HTTP-EQUIV="Content-Type" content="text/html; charset=utf-8">
<SCRIPT src="tiny-dash.js" type="text/javascript"></SCRIPT>
<SCRIPT>
var dash;
window.addEventListener( 'load', function() { dash = new Dash( 'dashvideo' ); }, false );
</SCRIPT>
<TITLE>Camera stream</TITLE>
</HEAD>
<BODY bgcolor="#000000" text="#c0c0c0">
<VIDEO id="dashvideo" src="media/manifest.mpd" preload="none">No video available</video>
</DIV>
</BODY>
На серваке из-под nobody запустить
#!/bin/sh
stream="rtsp://host:port/ch0_264"
target="/где.там.страничка.лежит/media/manifest.mpd"
/usr/bin/ffmpeg \
-probesize 32 \
-loglevel quiet \
-i "${stream}" \
-an \
-c:v copy \
-f dash \
-window_size 4 \
-extra_window_size 1 \
-min_seg_duration 2000000 \
-remove_at_exit 1 \
"${target}"
В /где.там.страничка.лежит/media
лучше примонтировать tmpfs - и шустро, и диск не трогает.
Длительность сегмента и размер окна можно подкрутить, если канал убогий.
Для руления плеером из жабоскрипта есть методы Dash.play()
, Dash.pause()
и Dash.toggle()
. Всякие кнопки и украшательства добавляются по вкусу.
Работает в хроме и фаерфоксе. Задержка, разумеется есть, но без неё, увы, при воспроизведении DASH не обойтись.
Решение отлично подходит для камер, которые нафиг не нужно куда-то записывать, но которые хотят смотреть много (и даже очень много) людей одновременно. HTTP сервер всего лишь раздаёт не особо большие файлики из tmpfs, так что нагрузка минимальна.
В принципе, это всё несложно использовать для организации self-hosted zoom при минимальной доработке - нужно только организовать передачу потоков с вебок на сервер (да хоть через netcat), а там их так же раскладывать в сегменты ffmpeg’ом.
Лицензия - WTFPL
ЗЫ: Хотел в теги добавить ещё «копрофилию» но ограничение на 5 тегов испортило всю малину.
ЗЗЫ: Это, кстати, эксклюзив специально для ЛОР. :) Нигде больше публиковать это я не собираюсь.