Змушуємо FFMPEG міняти HLS потоки в зависимостри від поточної пропускної спроможності

Привіт, жителі Хабра. Сьогодні хочу розповісти історію про те, як довелося пірнати в глибини 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. Це плеєр, який є обгорткою 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);
}


У pagemaker.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 %d\n",
v->index);
return AVERROR_EOF;
}

/* If this is a live stream and reload the 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, been removed 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 добвляем соответсвтующие згадки

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 },
...


Все, наша реалізація готова, тепер залишається перезібрати і дивитися відео з мінливими потокоми.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.