воскресенье, 12 августа 2012 г.

Квест: как под Android выдрать кадр из видеофайла средствами FFMpeg и передать его на сторону Java.

Программирование под Android очень часто напоминает настоящий квест. Шаг влево, шаг вправо - и ты попадаешь на минное поле, полное багов, особенностей реализации, девайсо-зависимых проблем и т.п. И так происходит каждый раз, как только начинаешь реализовывать что-нибудь более менее нетривиальное. Вот свежий пример. Потребовалось мне извлекать кадры из видеофайла и показывать их на экране. Казалось бы - что может быть проще?

Почему MediaMetadataRetriever не годится

В Android есть для этого необходимый класс - MediaMetadataRetriever, в котором предусмотрена функция - getFrameAtTime.

Первая проблема - MediaMetadataRetriever есть только начиная с API 10. Вторая проблема более серьезная - функция getFrameAtTime не работает как минимум под Android 2.X. Вместо того, чтобы возвращать требуемый фрейм, она всегда возвращает один и тот же фрейм. Причем это не баг, это фича! В документации прямо написано:
This method finds a representative frame close to the given time position by considering the given option if possible, and returns it as a bitmap. Returns: A Bitmap containing a representative video frame, which can be null, if such a frame cannot be retrieved.

Итак, MediaMetadataRetriever нам не подходит. Что можно использовать? Выбор невелик - библиотеку FFMpeg. Добро пожаловать в мир NDK...

Компиляция FFMpeg под Android

Компиляция FFMpeg под Android - задача непростая. К счастью, все таки решаемая. Вот здесь подробно описано как можно скомпилировать FFMpeg (огромное спасибо автору). Компиляцию, правда, приходится проводить под Ubuntu, но после компиляции скомпилированные библиотеки можно спокойно использовать под Windows (вот мои результаты компиляции FFMpeg под Android для arm6).

Извлечение кадра в битмапку средствами FFMpeg

Нативное API для извлечения кадра из видеофайла может быть, например, следующим.

На стороне Java

public class FFMpegWrapper {
    static {
        System.loadLibrary("sblib");
        initialize();
    } 
private static native int initialize();
public static native long openFile(String fileName);
public static native int getFrameBufferSize(long handleFile, int format, int width, int height);
public static native int getFrame(long handleFile, long timeUS, int width, int height, java.nio.Buffer buffer);

На стороне JNI

#include <jni.h>
#include <android/log.h>

#include "libavutil/pixfmt.h"
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include "libswscale/swscale.h"
#include "cmdutils.h"

#define LOG_TAG "com.domain.tag"
#define LOGI(...)  __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...)  __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

const int ERROR_OPEN_FILE = -1;
const int ERROR_FIND_VIDEOSTREAM = -2;
const int ERROR_FIND_VIDEODECODER = -3;
const int ERROR_OPEN_VIDEODECODER = -4;
const int ERROR_NO_STREAM_INFO = -5;

typedef long thandle_file;
// Handle для файла - полная информация об открытом файле 
struct thandle {
 AVFormatContext* ctx; 
 AVCodecContext* codecCtx;
 int videoStream;
 AVPacket* packet;
} tHandle;

//отправляем в JAVA исключение с кодом errorCode
void make_exception(JNIEnv *env, int errorCode) {
 LOGI("exception %d", errorCode);
 char buffer[32];
 sprintf(buffer, "%d", errorCode);
 (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/Exception"), buffer);
}

//инициализация 
jint Java_com_domain_applicationname_FFMpegWrapper_initialize(JNIEnv *env) {
 av_register_all();
 LOGI("initialize_passed");
 return 0;
}

//определить размер кадра в формате Bitmap.Config.ARGB_8888
jint Java_com_domain_applicationname_FFMpegWrapper_getFrameBufferSize(JNIEnv *env, jobject thiz, thandle_file handleFile, jint width, jint height);

//открыть файл, получить handle
jint Java_com_domain_applicationname_FFMpegWrapper_openFile(JNIEnv *env, jobject thiz, jstring fileName) {
AVFormatContext* ctx = 0;
const char *filename = (*env)->GetStringUTFChars(env, fileName, 0);

if(av_open_input_file(&ctx, filename, NULL, 0, NULL) != 0) {
  make_exception(env, ERROR_OPEN_FILE); //"Не удалось открыть видеофайл
}

// Retrieve stream information
if(av_find_stream_info(ctx)< 0) {
  make_exception(env, ERROR_NO_STREAM_INFO); //"Не удалось открыть видеофайл; // Couldn't find stream information
}

int videoStream = -1;
for (int i = 0; i < ctx->nb_streams; ++i) {
 if (ctx->streams[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO) {
  videoStream = i;
  break;
 }
}
if (videoStream == -1) make_exception(env, ERROR_FIND_VIDEOSTREAM); 

AVCodecContext* codecCtx = ctx->streams[videoStream]->codec;
AVCodec* codec = avcodec_find_decoder(codecCtx->codec_id);

if (codec == 0) {
 make_exception(env, ERROR_FIND_VIDEODECODER); 
}

if (avcodec_open2(codecCtx, codec, 0) != 0) {
 make_exception(env, ERROR_OPEN_VIDEODECODER); 
}
AVPacket* packet = (AVPacket*)malloc(sizeof(struct AVPacket));
av_init_packet(packet);

struct thandle* h = malloc(sizeof(struct thandle));
h->ctx = ctx;
h->codecCtx = codecCtx;
h->videoStream = videoStream;
h->packet = packet;

//release java string
(*env)->ReleaseStringUTFChars(env, fileName, filename);

return (unsigned long)h;
}

//выдрать из открытого файла фрейм в формате Bitmap.Config.ARGB_8888
//в заданных размерах
//и сохранить битмапку в переданный Java объект java.nio.ByteBuffer 
//время timeUS задается в микросекундах.
jint Java_com_domain_applicationname_FFMpegWrapper_getFrame(JNIEnv *env, jobject thiz, thandle_file handleFile, jlong timeUS, jint width, jint height, jobject buffer) {

//Функция для выдирания заданного фрейма из открытого файла
AVFormatContext* ctx = ((struct thandle*)handleFile)->ctx; 
AVCodecContext* codecCtx = ((struct thandle*)handleFile)->codecCtx;
AVPacket* packet =  ((struct thandle*)handleFile)->packet;
int videoStream = ((struct thandle*)handleFile)->videoStream;
jshort* buff = (jshort*) (*env)->GetDirectBufferAddress(env, shortBuffer);

AVFrame* frame = avcodec_alloc_frame(); //YUV frame
avcodec_get_frame_defaults(frame);

//AV_TIME_BASE * time_in_seconds = avcodec_timestamp
//см. http://dranger.com/ffmpeg/tutorial07.html
int64_t pos = frameNumber * AV_TIME_BASE / 1000000; 
int64_t seek_target= av_rescale_q(pos, AV_TIME_BASE_Q, ctx->streams[videoStream]->time_base);

//выполняем seek - попадаем на ближайший кейфрейм
//затем ищем нужный кадр. 
int res = avformat_seek_file(ctx
 , videoStream
 , INT64_MIN
 , seek_target//* AV_TIME_BASE
 , INT64_MAX
 , 0);
LOGI("seek: %d f=%ld pos=%lld st=%lld", res, frameNumber, (int64_t)pos, seek_target); 
if (res >= 0) {
 avcodec_flush_buffers(codecCtx);
 LOGI("flushed"); 
}
av_init_packet(packet);

AVFrame* frameRGB = avcodec_alloc_frame();
avcodec_get_frame_defaults(frameRGB);

enum PixelFormat pixel_format = PIX_FMT_RGBA; 
avpicture_fill((AVPicture*) frameRGB
 , (uint8_t*)buff 
 , pixel_format
 , width 
 , height 
);

while (av_read_frame(ctx, packet) == 0) {
 LOGI("pts=%lld st=%lld", packet->pts, seek_target); 
 if (packet->stream_index == videoStream) {
  int gotPicture = 0;
  int bytesDecompressed = avcodec_decode_video2(codecCtx, frame, &gotPicture, packet);
    if (gotPicture && packet->pts >= seek_target) {
    // конвертируем данные из формата YUV в RGB24
    struct SwsContext* scaleCtx = sws_getContext(frame->width, 
      frame->height, 
      (enum PixelFormat)frame->format
      , width //frame->width, 
      , height //frame->height, 
      , pixel_format
      , SWS_BICUBIC
      , 0, 0, 0);

    int height = sws_scale(scaleCtx
      , frame->data
      , frame->linesize
      , 0
      , frame->height
      , frameRGB->data
      , frameRGB->linesize);
   break;
   }
  av_free_packet(packet);
  }
}
av_free(frameRGB);
av_free(frame);
return 0;
}

Передача кадра из JNI в Java, используя allocateDirect

Итак, для получения кадра нам нужно передать в функцию getFrame буфер - объект типа java.nio.ByteBuffer. Как выделить память в буфере?

Первое, что приходит в голову - воспользоваться функцией allocateDirect. Собственно, она для этих целей и предназначена. Получаем:
//На стороне JNI:
jshort* buff = (jshort*) (*env)->GetDirectBufferAddress(env, shortBuffer);
... //используем buff в функциях FFMpeg

//На стороне Java:
ByteBuffer my_buffer = ByteBuffer.allocateDirect(bufferSize).asShortBuffer();
FFMpegWrapper.getFrame(handle, timeUS, width, height, my_buffer);
Bitmap dest = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
dest.copyPixelsFromBuffer(buffer); 
//Ура, мы получили в dest битмапку c извлеченным кадром :)

Все просто.. но таким образом очень легко получить Out Of Memory. Причин, как минимум, две:
  • Функция allocateDirect выделяет буфер в нативной памяти, тогда как сам Java-объект ByteBuffer занимает минимум места в управляемой памяти. Предположим, вы выделили с помощью allocateDirect память размером 10 Мб. Как только буфер стал ненужен, память нужно освободить. Но такой возможности у нас нет - предполагается, что сборщик мусора сам освободит эту память, когда придет время. Сборщик же не торопится - с его точки зрения ByteBuffer занимает не 10 Мб, а несколько байт, чего торопиться его удалять? Детально проблема описана вот здесь. На практике несколько последовательных allocateDirect по 10 Мб легко дают Out Of Memory.
  • Под Android 3.X функция allocateDirect работает некорректно - она выделяет в 4 раза больше памяти, чем запрашиваешь.

Альтернативный вариант выделения памяти под буфер

Исправить ситуацию можно, выделяя и освобождая память самостоятельно, не используя ByteBuffer.allocateDirect. Например, как это описано здесь.

Функции на стороне JNI

jobject Java_com_domain_applicationname_selector_FFMpegWrapper_allocNative(JNIEnv* env, jobject thiz, jlong size)
{
 void* buffer = malloc(size);
  jobject directBuffer = (*env)->NewDirectByteBuffer(env, buffer, size);
  jobject globalRef = (*env)->NewGlobalRef(env, directBuffer);
  return globalRef;
}
void Java_com_domain_applicationname_FFMpegWrapper_freeNative(JNIEnv* env, jobject thiz, jobject globalRef)
{
    void *buffer = (*env)->GetDirectBufferAddress(env, globalRef);
    free(buffer);
    (*env)->DeleteGlobalRef(env, globalRef);
}

На стороне Java

public class FFMpegWrapper {
    .........
    /** Выделить нативный буфер заданного размера*/
    public static native ByteBuffer allocNative(long bufferSize);

    /** Выделить нативный буфер заданного размера*/
    public static native void freeNative(ByteBuffer buffer);
}

ByteBuffer my_buffer = FFMpegWrapper.allocNative(bufferSize).asShortBuffer();
FFMpegWrapper.getFrame(handle, timeUS, width, height, my_buffer);
Bitmap dest = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
dest.copyPixelsFromBuffer(buffer);
//Ура, мы получили в dest битмапку c извлеченным кадром :)

...
FFMpegWrapper.freeNative(my_buffer);

Выводы

А выводы очень просты :D Когда планируешь разработку приложения под Android (а тем более, пишешь ТЗ для заказчики с оценкой временных затрат), следует учитывать, что разработка под Android - это вот такой квест. И что простейшая задача, которая казалось бы решается за 15 минут, может вылиться в несколько недель тяжелой работы...

Комментариев нет:

Отправить комментарий