Привет, жители Хабра. Сегодня хочу рассказать историю о том, как пришлось нырять в глубины ffmpeg без подготовки. Эта статья будет руководством для тех, кому нужна возможность корректной работы FFMPEG c HLS стримами (а именно — смена потоков в зависимостри от текущей пропускной способности сети).
Начнем немного с предыстории. Не так давно у нас появился проект, 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 потока, аппаратные кодеки просто падали и ни одно видео нb работало, не только в нашем приложение, а и в других приложениях, которые используют аппаратные кодеки. Единственный способ поднять их — это перезагрузить девайс. Как оказалось, этой задаче была посвящена тема на гитхабе. Как стало ясно, использовать аппаратные кодеки мы не можем, значит, нужно использовать программные кодеки и я напомню, что основная задача была играть 4 видео одновременно.
И начался велики пойиск и искали мы долго и пробовали мы многое, но единственное, что нас устроило, было IJKPlayer. Это плеер, который является оберткой ffmep. Он воспроизводил 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 "test.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){
int n_selected_url = strlen(selected_url);
int n_url = strlen(url);
if(n_selected_url != n_url)
return 0;
int index = 0;
while(index < n_selected_url){
if(selected_url[index] != url[index]){
return 0;
}
index++;
}
}
return 1;
}
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 добавляем
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 будет выглядеть так
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 %dn",
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 %dn",
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 playlistsn",
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 %dn",
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 добвляем соответсвтующие упоминания
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, который будем вызывать после завершения просмотра, что бы очистить данные.
static void
IjkMediaPlayer_freeBitateWorkData(JNIEnv *env, jclass clazz){
freeData();
}
//и добавляем данный метод в массив g_methods
...
{ "_freeBitateWorkData", "()V", (void *)IjkMediaPlayer_freeBitateWorkData },
...
Все, наша реализация готова, теперь остается пересобрать и смотреть видео с меняющимися потокоми.
Автор: zo2m4bie