Привет, жители Хабра. Сегодня хочу рассказать историю о том, как пришлось нырять в глубины ffmpeg без подготовки. Эта статья будет руководством для тех, кому нужна возможность корректной работы FFMPEG c HLS стримами (а именно — смена потоков в зависимостри от текущей пропускной способности сети).
Начнем немного с предыстории. Не так давно у нас появился проект, android tv, в котором одна из фич была воспроизведение сразу несколько видео одновременно, то есть юзер смотрит на экран и видит 4 видео. Потом выбирает одно из них и смотрит его уже в фул скрине. Задача ясна, осталось только сделать. Особенность в том, что видео приходит в формате HLS. Я думаю, что если вы читаете это, то уже знакомы с HLS, но все же вкратце — нам дается файл, в котом есть ссылки на несколько потоков, которые должны меняться в зависимости от текущей скорости интернета.
Первым же делом мы начали реализовывать даную фичу черер EXOPlayer. Что было достаточно логично, так как EXOPlayer использует аппаратные кодеки для воспроизведения видео потока. Но оказалось, что у EXO есть своя темная сторона. Когда с помощью EXO запускается больше, чем один поток, никто не знает, что произойдет. В нашем случае, когда мы запускали 4 потока, на некоторых девайсах все работало хорошо, на некоторых работало только 3, а четвертый не запускался, а на некоторых, например, на Nexus 7 2013 происходило кое-что другое. Когда Nexus 72013 запускал больше 1 потока, аппаратные кодеки просто падали и ни одно видео не работало, не только в нашем приложении, но и в других приложениях, которые используют аппаратные кодеки. Единственный способ поднять их — это перезагрузить девайс. Как оказалось, этой задаче была посвящена тема на гитхабе. Как стало ясно, использовать аппаратные кодеки мы не можем, значит, нужно использовать программные кодеки и я напомню, что основная задача была играть 4 видео одновременно.
И начался велики поиск и искали мы долго и пробовали мы многое, но единственное, что нас устроило, было IJKPlayer. Это плеер, который является оберткой ffmpeg. Он воспроизводил HLS, играл их в 4 потока, а так же воспроизводил другие потоки, которые EXOplayer играл не на всех девайсах (например, HEVC). И очень долго все было хорошо, пока мы не начали замечать, что плеер всегда играет один и тот же поток и не меняет его в зависимости от пропускной способности сети. Для маленьких видео привью это не было проблемой, а вот для фул скрина это была проблема.
Поискав, оказалось, что потоки не меняются, а сам хозяин IJKPplayer посоветовал парсить потоки отдельно от плеера и запускать именно тот, что нужен (так же тикет с ffmpeg). Естественно, это не подходило потому что плеер должен сам подстраиваться относительно интернета. Проблема проблемой, а решать ее надо. В интернете ничего не получилось найти так, что было принято решение самолично добавить в либу логику по смене потоков. Но перед, тем как что-то делать, надо понять, где это делать. Сам FFMPEG является очень большой либой и не так просто понять, что есть что, но я выделил для вас несколько основных мест, с которыми нам нужно будет работать.
Итак, основные моменты, которые нам нужно знать:
Итак, подытожим наши задачи:
Листинг:
Теперь переходим к листингу самого ffmpeg
В avio.c добавляем
В hls.c метод read_data будет выглядеть так
Остались мелочи добавляем новые файлы в makefile внутри libavformat в HEADERS и OBJS добвляем соответсвтующие упоминания
Также добавляем метод IjkMediaPlayer_freeBitateWorkData в ijkplayer_jni.c, который будем вызывать после завершения просмотра, что бы очистить данные.
Все, наша реализация готова, теперь остается пересобрать и смотреть видео с меняющимися потокоми.
Начнем немного с предыстории. Не так давно у нас появился проект, android tv, в котором одна из фич была воспроизведение сразу несколько видео одновременно, то есть юзер смотрит на экран и видит 4 видео. Потом выбирает одно из них и смотрит его уже в фул скрине. Задача ясна, осталось только сделать. Особенность в том, что видео приходит в формате HLS. Я думаю, что если вы читаете это, то уже знакомы с HLS, но все же вкратце — нам дается файл, в котом есть ссылки на несколько потоков, которые должны меняться в зависимости от текущей скорости интернета.
Пример:
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=688301
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0640_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=165135
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0150_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=262346
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0240_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=481677
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0440_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1308077
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/1240_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1927853
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/1840_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=2650941
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/2540_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=3477293
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/3340_vod.m3u8
Первым же делом мы начали реализовывать даную фичу черер EXOPlayer. Что было достаточно логично, так как EXOPlayer использует аппаратные кодеки для воспроизведения видео потока. Но оказалось, что у EXO есть своя темная сторона. Когда с помощью EXO запускается больше, чем один поток, никто не знает, что произойдет. В нашем случае, когда мы запускали 4 потока, на некоторых девайсах все работало хорошо, на некоторых работало только 3, а четвертый не запускался, а на некоторых, например, на Nexus 7 2013 происходило кое-что другое. Когда Nexus 72013 запускал больше 1 потока, аппаратные кодеки просто падали и ни одно видео не работало, не только в нашем приложении, но и в других приложениях, которые используют аппаратные кодеки. Единственный способ поднять их — это перезагрузить девайс. Как оказалось, этой задаче была посвящена тема на гитхабе. Как стало ясно, использовать аппаратные кодеки мы не можем, значит, нужно использовать программные кодеки и я напомню, что основная задача была играть 4 видео одновременно.
И начался велики поиск и искали мы долго и пробовали мы многое, но единственное, что нас устроило, было IJKPlayer. Это плеер, который является оберткой ffmpeg. Он воспроизводил HLS, играл их в 4 потока, а так же воспроизводил другие потоки, которые EXOplayer играл не на всех девайсах (например, HEVC). И очень долго все было хорошо, пока мы не начали замечать, что плеер всегда играет один и тот же поток и не меняет его в зависимости от пропускной способности сети. Для маленьких видео привью это не было проблемой, а вот для фул скрина это была проблема.
Поискав, оказалось, что потоки не меняются, а сам хозяин IJKPplayer посоветовал парсить потоки отдельно от плеера и запускать именно тот, что нужен (так же тикет с ffmpeg). Естественно, это не подходило потому что плеер должен сам подстраиваться относительно интернета. Проблема проблемой, а решать ее надо. В интернете ничего не получилось найти так, что было принято решение самолично добавить в либу логику по смене потоков. Но перед, тем как что-то делать, надо понять, где это делать. Сам FFMPEG является очень большой либой и не так просто понять, что есть что, но я выделил для вас несколько основных мест, с которыми нам нужно будет работать.
Итак, основные моменты, которые нам нужно знать:
- Есть метод read_data, который находится в libavformat/hls.c, здесь происходит основная магия. Здесь мы скачиваем поток и кладем его в буфер. А в конце метода есть goto restart, где и происходит смена сегмента. Перед этим рестартом мы и будем заменять поток, если это будет нам нужно.
- Второй объект, который нас интересует — это libavformat/avio.c. Здесь есть метод ffurl_close, который вызывается когда ссылка закрывается, а значит, здесь мы будем подытоживать текущую пропускную способность. А так же метод ffurl_open, который, конечно же, открывает наш поток, а значит здесь мы будем обнулять счетчик загруженных данных, а так же перезапускать таймер.
- Так же будет неплохо обратить ваше внимание на методы new_variant и new_playlist — в них создается плейлист со всех возможных битрейтов. По моим наблюдениям плеер берет первый айтем из списка и играет его, если произошла какая-то ошибка, то он берет второй айтем. Если вам необходимо сделать так, чтобы игрался только самый маленький (что логично, если воспроизводить 4 потока одновременно) или самый большой поток, то обратите внимание на эти методы.
Итак, подытожим наши задачи:
- Вычислить текущую пропускную способность
- Подменить ссылку, если это необходимо, для соответственной пропускной способности
- Почистить данные после того, как юзер перестанет смотреть видео
Листинг:
bitrate_manager.h
#include <stdint.h>
#ifndef IJKPLAYER_TEST_H
#define IJKPLAYER_TEST_H
extern int64_t start_loading;
extern int64_t end_loading ;
extern int64_t loaded_bytes;
extern int64_t currentBitrate;
extern int64_t diff;
//массив ссылков
extern char** urls;
//массив пропускных способнойстей, соответствующий массиву ссылок выше
extern int64_t* bandwidth;
extern int n_arrays_items;
extern char* selected_url;
extern int current_url_index;
extern int64_t current_bandwidth;
void saveStartLoadingData();
int64_t getStartLoading();
//проверяем инициализирован ли менеджер
int isInited();
//добавляем к счетчику скачаных байтов, количество скачаных байт за один раз
void addToLoadingByte(int64_t bytesCount);
//конец загрузки данного сегмента, считаем время затраченое на текущую операцию загрузки сегмента
void endOfLoading();
//высчитываем текущий битрейт
void calculateAndSaveCurrentBitrate();
int64_t getDiff();
int64_t getLoadedBites();
int64_t getEndLoading();
int64_t getCurrentBitrate();
void setFullUrl(char* url);
void setParturlParts();
//Есть ли у нас вообще битрейты
int doWeHaveBadwidth();
//создаем массив ссылок
void createDataArrays(int n_items);
//заполняем массив ссылок
void addData(int i, char* url, int64_t band_width);
//освобождаем память
void freeData();
//возвращаем текущую выбранную ссылку
char* getCurrentUrl();
//сравниваем ссылку с текущей выбранной ссылкой
int compareUrl(char* url);
//находим поток подходящий под тукущую пропускную способность
void findBestSolutionForCurrentBandwidth();
char* getUrlString(int index);
#endif //IJKPLAYER_TEST_H
bitrate_manager.c
#include "bitrate_manager.h"
#include <time.h>
#include <stdint.h>
#include <string.h>
#include "libavutil/log.h"
static const int64_t ONE_SECOND= 1000000000LL;
int64_t start_loading;
int64_t end_loading ;
int64_t loaded_bytes;
int64_t currentBitrate;
int64_t diff;
char** urls;
int64_t* bandwidth;
int n_arrays_items;
char* selected_url;
int current_url_index;
int64_t current_bandwidth;
/*
* It conyains current last index + 1
*/
int pointerAfterLastItem;
int isInitedData = 0;
int64_t now_ms() {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t) now.tv_sec*1000000000LL + now.tv_nsec;
}
void saveStartLoadingData(){
loaded_bytes = 0LL;
start_loading = now_ms();
}
int64_t getStartLoading(){
return start_loading;
}
int isInited(){
return isInitedData;
}
void addToLoadingByte(int64_t bytesCount){
loaded_bytes += bytesCount;
}
void endOfLoading(){
end_loading = now_ms();
diff = end_loading - start_loading;
}
void calculateAndSaveCurrentBitrate(){
if(loaded_bytes != 0) {
currentBitrate = loaded_bytes * ONE_SECOND / diff;
}
loaded_bytes = 0;
}
int64_t getDiff(){
return diff;
}
int64_t getLoadedBites(){
return loaded_bytes;
}
int64_t getEndLoading(){
return end_loading;
}
int64_t getCurrentBitrate(){
return currentBitrate;
}
int doWeHaveBadwidth(){
if(bandwidth && pointerAfterLastItem != 0){
return 1;
}
return 0;
}
void createDataArrays(int n_items){
isInitedData = 1;
pointerAfterLastItem = 0;
n_arrays_items = n_items;
bandwidth = (int64_t*) malloc(n_items * sizeof(int64_t));
urls = (char**) malloc(n_items * sizeof(char*));
for(int i =0; i < n_items; i++){
urls[i] = (char*) malloc(sizeof(char));
}
}
void addData(int i, char* url, int64_t band_width){
if(band_width == 0LL){
return;
}
free(urls[i]);
urls[i] = (char*) malloc(strlen(url) * sizeof(char));
strcpy(urls[pointerAfterLastItem], url);
bandwidth[pointerAfterLastItem] = band_width;
pointerAfterLastItem++;
}
void freeData(){
if(isInitedData == 0){
return;
}
isInitedData = 0;
for(int i = 0;i < pointerAfterLastItem;++i) free(urls[i]);
free(urls);
free(bandwidth);
}
char* getCurrentUrl(){
return selected_url;
}
int compareUrl(char* url){
if(selected_url){
return strcmp(selected_url, url);
}
return 0;
}
void findBestSolutionForCurrentBandwidth() {
if (currentBitrate == 0) {
selected_url = urls[0];
current_url_index = 0;
current_bandwidth = bandwidth[0];
return;
}
if (currentBitrate == current_bandwidth) return;
int index = 0;
int64_t selectedBitrate = bandwidth[index];
int start = 0;
int length = pointerAfterLastItem;
for (int i = start; i < length; i++) {
if (currentBitrate >= bandwidth[i]
&& selectedBitrate <= bandwidth[i]) {
index = i;
selectedBitrate = bandwidth[i];
}
}
if (current_bandwidth != selectedBitrate) {
selected_url = urls[index];
current_url_index = index;
current_bandwidth = selectedBitrate;
}
}
Теперь переходим к листингу самого ffmpeg
В avio.c добавляем
avio.c
int ffurl_open(URLContext **puc, const char *filename, int flags,
const AVIOInterruptCB *int_cb, AVDictionary **options)
{
if(isInited() == 1) {
saveStartLoadingData();
}
….
}
….
int ffurl_close(URLContext *h)
{
if( isInited() == 1) {
endOfLoading();
calculateAndSaveCurrentBitrate();
}
return ffurl_closep(&h);
}
В hls.c метод read_data будет выглядеть так
hls.c
static int read_data(void *opaque, uint8_t *buf, int buf_size)
{
struct playlist *v = opaque;
HLSContext *c = v->parent->priv_data;
// инициализируем плейлист
if (isInited() == 0) {
createDataArrays(c->n_variants);
for (int i = 0; i < c->n_variants; i++) {
addData(i, c->playlists[i]->url, c->variants[i]->bandwidth);
}
}
//при необходимости, подменяем ссылки
if(doWeHaveBadwidth() == 1 && isInited() == 1 && compareUrl(v->url) != 0){
strcpy(v->url, getCurrentUrl());
}
int ret, i;
int just_opened = 0;
restart:
if (!v->needed)
return AVERROR_EOF;
if (!v->input) {
int64_t reload_interval;
/* Check that the playlist is still needed before opening a new
* segment. */
if (v->ctx && v->ctx->nb_streams &&
v->parent->nb_streams >= v->stream_offset + v->ctx->nb_streams) {
v->needed = 0;
for (i = v->stream_offset; i < v->stream_offset + v->ctx->nb_streams;
i++) {
if (v->parent->streams[i]->discard < AVDISCARD_ALL)
v->needed = 1;
}
}
if (!v->needed) {
av_log(v->parent, AV_LOG_INFO, "No longer receiving playlist %d\n",
v->index);
return AVERROR_EOF;
}
/* If this is a live stream and the reload interval has elapsed since
* the last playlist reload, reload the playlists now. */
reload_interval = default_reload_interval(v);
reload:
if (!v->finished &&
av_gettime_relative() - v->last_load_time >= reload_interval) {
if ((ret = parse_playlist(c, v->url, v, NULL)) < 0) {
av_log(v->parent, AV_LOG_WARNING, "Failed to reload playlist %d\n",
v->index);
return ret;
}
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
/* If we need to reload the playlist again below (if
* there's still no more segments), switch to a reload
* interval of half the target duration. */
reload_interval = v->target_duration / 2;
}
if (v->cur_seq_no < v->start_seq_no
|| v->cur_seq_no > (v->start_seq_no + (v->n_segments * 5)) ) {
av_log(NULL, AV_LOG_WARNING,
"skipping %d segments ahead, expired from playlists\n",
v->start_seq_no - v->cur_seq_no);
v->cur_seq_no = v->start_seq_no;
}
if (v->cur_seq_no >= v->start_seq_no + v->n_segments) {
if (v->finished)
return AVERROR_EOF;
while (av_gettime_relative() - v->last_load_time < reload_interval) {
if (ff_check_interrupt(c->interrupt_callback))
return AVERROR_EXIT;
av_usleep(100*1000);
}
/* Enough time has elapsed since the last reload */
goto reload;
}
ret = open_input(c, v);
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
if (ret < 0) {
if (ff_check_interrupt(c->interrupt_callback))
return AVERROR_EXIT;
av_log(v->parent, AV_LOG_WARNING, "Failed to open segment of playlist %d\n",
v->index);
v->cur_seq_no += 1;
goto reload;
}
just_opened = 1;
}
ret = read_from_url(v, buf, buf_size, READ_NORMAL);
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
if (ret > 0) {
if (just_opened && v->is_id3_timestamped != 0) {
/* Intercept ID3 tags here, elementary audio streams are required
* to convey timestamps using them in the beginning of each segment. */
intercept_id3(v, buf, buf_size, &ret);
}
return ret;
}
ffurl_close(v->input);
v->input = NULL;
v->cur_seq_no++;
c->cur_seq_no = v->cur_seq_no;
// загрузка была завершена. Ищем подходящюю ссылку для текущего bandwidth если она отличается то заменяем страую ссылку на новую
if(isInited() == 1
&& doWeHaveBadwidth() == 1) {
findBestSolutionForCurrentBandwidth();
if (compareUrl(v->url) != 0) {
strcpy(v->url, getCurrentUrl());
}
}
goto restart;
}
Остались мелочи добавляем новые файлы в makefile внутри libavformat в HEADERS и OBJS добвляем соответсвтующие упоминания
makefile
NAME = avformat
HEADERS = avformat.h \
avio.h \
version.h \
avc.h \
url.h \
internal.h \
bitrate_mamnger.h \
OBJS = allformats.o \
avio.o \
aviobuf.o \
cutils.o \
dump.o \
format.o \
id3v1.o \
id3v2.o \
metadata.o \
mux.o \
options.o \
os_support.o \
riff.o \
sdp.o \
url.o \
utils.o \
avc.o \
bitrate_mamnger.o \
Также добавляем метод IjkMediaPlayer_freeBitateWorkData в ijkplayer_jni.c, который будем вызывать после завершения просмотра, что бы очистить данные.
ijkplayer_jni.c
static void
IjkMediaPlayer_freeBitateWorkData(JNIEnv *env, jclass clazz){
freeData();
}
//и добавляем данный метод в массив g_methods
...
{ "_freeBitateWorkData", "()V", (void *)IjkMediaPlayer_freeBitateWorkData },
...
Все, наша реализация готова, теперь остается пересобрать и смотреть видео с меняющимися потокоми.