Как стать автором
Обновить

Заставляем FFMPEG менять HLS потоки в зависимости от текущей пропускной способности

Время на прочтение 9 мин
Количество просмотров 14K
Привет, жители Хабра. Сегодня хочу рассказать историю о том, как пришлось нырять в глубины 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 потока, аппаратные кодеки просто падали и ни одно видео не работало, не только в нашем приложении, но и в других приложениях, которые используют аппаратные кодеки. Единственный способ поднять их — это перезагрузить девайс. Как оказалось, этой задаче была посвящена тема на гитхабе. Как стало ясно, использовать аппаратные кодеки мы не можем, значит, нужно использовать программные кодеки и я напомню, что основная задача была играть 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 },
...



Все, наша реализация готова, теперь остается пересобрать и смотреть видео с меняющимися потокоми.
Теги:
Хабы:
+25
Комментарии 24
Комментарии Комментарии 24

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн