FFmpeg 入门(1):截取视频帧

本文转自:FFmpeg 入门(1):截取视频帧 | www.samirchen.com

背景

在 Mac OS 上如果要运行教程中的相关代码需要先安装 FFmpeg,建议使用 brew 来安装:

// 用 brew 安装 FFmpeg:
brew install ffmpeg

或者你可以参考在 Mac OS 上编译 FFmpeg使用源码编译和安装 FFmpeg。

教程原文地址:http://dranger.com/ffmpeg/tutorial01.html,本文中的代码做过部分修正。

概要

媒体文件通常有一些基本的组成部分。首先,文件本身被称为「容器(container)」,容器的类型定义了文件的信息是如何存储,比如,AVI、QuickTime 等容器格式。接着,你需要了解的概念是「流(streams)」,例如,你通常会有一路音频流和一路视频流。流中的数据元素被称为「帧(frames)」。每路流都会被相应的「编/解码器(codec)」进行编码或解码(codec 这个名字就是源于 COded 和 DECoded)。codec 定义了实际数据是如何被编解码的,比如你用到的 codecs 可能是 DivX 和 MP3。「数据包(packets)」是从流中读取的数据片段,这些数据片段中包含的一个个比特就是解码后能最终被我们的应用程序处理的原始帧数据。为了达到我们音视频处理的目标,每个数据包都包含着完整的帧,在音频情况下,一个数据包中可能会包含多个音频帧。

基于以上这些基础,处理视频流和音频流的过程其实很简单:

  • 1:从 video.avi 文件中打开 video_stream。
  • 2:从 video_stream 中读取数据包到 frame。
  • 3:如果数据包中的 frame 不完整,则跳到步骤 2。
  • 4:处理 frame。
  • 5:跳到步骤 2。

尽管在一些程序中上面步骤 4 处理 frame 的逻辑可能会非常复杂,但是在本文中的例程中,用 FFmpeg 来处理多媒体文件的部分会写的比较简单一些,这里我们将要做的就是打开一个媒体文件,读取其中的视频流,将视频流中获取到的视频帧写入到 PPM 文件中保存起来。

下面我们一步一步来实现。

打开媒体文件

首先,我们来看看如何打开媒体文件。在使用 FFmpeg 时,首先需要初始化对应的 Library。

#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
//...

int main(int argc, char *argv[]) {

    // Register all formats and codecs.
    av_register_all();

    // ...
}

上面的代码会注册 FFmpeg 库中所有可用的「视频格式」和 「codec」,这样当使用库打开一个媒体文件时,就能找到对应的视频格式处理程序和 codec 来处理。需要注意的是在使用 FFmpeg 时,你只需要调用 av_register_all() 一次即可,因此我们在 main 中调用。当然,你也可以根据需求只注册给定的视频格式和 codec,但通常你不需要这么做。

接下来我们就要准备打开媒体文件了,那么媒体文件中有哪些信息是值得注意的呢?

  • 是否包含:音频、视频。
  • 码流的封装格式,用于解封装。
  • 视频的编码格式,用于初始化视频解码器
  • 音频的编码格式,用于初始化音频解码器。
  • 视频的分辨率、帧率、码率,用于视频的渲染。
  • 音频的采样率、位宽、通道数,用于初始化音频播放器。
  • 码流的总时长,用于展示、拖动 Seek。
  • 其他 Metadata 信息,如作者、日期等,用于展示。

这些关键的媒体信息,被称作 metadata,常常记录在整个码流的开头或者结尾处,例如:wav 格式主要由 wav header 头来记录音频的采样率、通道数、位宽等关键信息;mp4 格式,则存放在 moov box 结构中;而 FLV 格式则记录在 onMetaData 中等等。

avformat_open_input 这个函数主要负责服务器的连接和码流头部信息的拉取,我们就用它来打开媒体文件:

AVFormatContext *pFormatCtx = NULL;

// Open video file.
if (avformat_open_input(&pFormatCtx, argv[1], NULL, NULL) != 0) {
    return -1; // Couldn't open file.
}

我们从程序入口获得要打开文件的路径,作为 avformat_open_input 函数的第二个参数传入,这个函数会读取媒体文件的文件头并将文件格式相关的信息存储在我们作为第一个参数传入的 AVFormatContext 数据结构中。avformat_open_input 函数的第三个参数用于指定媒体文件格式,第四个参数是文件格式相关选项。如果你后面这两个参数传入的是 NULL,那么 libavformat 将自动探测文件格式。

接下来对于媒体信息的探测和分析工作就要交给 avformat_find_stream_info 函数了:

// Retrieve stream information.
if (avformat_find_stream_info(pFormatCtx, NULL) < 0) {
    return -1; // Couldn't find stream information.
}

avformat_find_stream_info 函数会为 pFormatCtx->streams 填充对应的信息。这里还有一个调试用的函数 av_dump_format 可以为我们打印 pFormatCtx 中都有哪些信息。

// Dump information about file onto standard error.
av_dump_format(pFormatCtx, 0, argv[1], 0);

AVFormatContext 里包含了下面这些跟媒体信息有关的成员:

  • struct AVInputFormat *iformat; // 记录了封装格式信息
  • unsigned int nb_streams; // 记录了该 URL 中包含有几路流
  • AVStream **streams; // 一个结构体数组,每个对象记录了一路流的详细信息
  • int64_t start_time; // 第一帧的时间戳
  • int64_t duration; // 码流的总时长
  • int64_t bit_rate; // 码流的总码率,bps
  • AVDictionary *metadata; // 一些文件信息头,key/value 字符串

你拿到这些数据后,与 av_dump_format 的输出对比可能会发现一些不同,这时候可以去看看 FFmpeg 源码中 av_dump_format 的实现,里面对打印出来的数据是有一些处理逻辑的。比如对于 start_time 的处理代码如下:

if (ic->start_time != AV_NOPTS_VALUE) {
    int secs, us;
    av_log(NULL, AV_LOG_INFO, ", start: ");
    secs = ic->start_time / AV_TIME_BASE;
    us = llabs(ic->start_time % AV_TIME_BASE);
    av_log(NULL, AV_LOG_INFO, "%d.%06d", secs, (int) av_rescale(us, 1000000, AV_TIME_BASE));
}

由此可见,经过 avformat_find_stream_info 的处理,我们可以拿到媒体资源的封装格式、总时长、总码率了。此外 pFormatCtx->streams 是一个 AVStream 指针的数组,里面包含了媒体资源的每一路流信息,数组的大小为 pFormatCtx->nb_streams

AVStream 结构体中关键的成员包括:

  • AVCodecContext *codec; // 记录了该码流的编码信息
  • int64_t start_time; // 第一帧的时间戳
  • int64_t duration; // 该码流的时长
  • int64_t nb_frames; // 该码流的总帧数
  • AVDictionary *metadata; // 一些文件信息头,key/value 字符串
  • AVRational avg_frame_rate; // 平均帧率

这里可以拿到平均帧率。

AVCodecContext 则记录了一路流的具体编码信息,其中关键的成员包括:

  • const struct AVCodec *codec; // 编码的详细信息
  • enum AVCodecID codec_id; // 编码类型
  • int bit_rate; // 平均码率
  • video only:
    • int width, height; // 图像的宽高尺寸,码流中不一定存在该信息,会由解码后覆盖
    • enum AVPixelFormat pix_fmt; // 原始图像的格式,码流中不一定存在该信息,会由解码后覆盖
  • audio only:
    • int sample_rate; // 音频的采样率
    • int channels; // 音频的通道数
    • enum AVSampleFormat sample_fmt; // 音频的格式,位宽
    • int frame_size; // 每个音频帧的 sample 个数

可以看到编码类型、图像的宽度高度、音频的参数都在这里了。

了解完这些数据结构,我们接着往下走,直到我们找到一个视频流:

// Find the first video stream.
videoStream = -1;
for (i = 0; i < pFormatCtx->nb_streams; i++) {
    if(pFormatCtx->streams[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO) {
        videoStream = i;
        break;
    }
}
if (videoStream == -1) {
    return -1; // Didn't find a video stream.
}

// Get a pointer to the codec context for the video stream.
pCodecCtxOrig = pFormatCtx->streams[videoStream]->codec;

流信息中关于 codec 的部分存储在 codec context 中,这里包含了这路流所使用的所有的 codec 的信息,现在我们有一个指向它的指针了,但是我们接着还需要找到真正的 codec 并打开它:

// Find the decoder for the video stream.
pCodec = avcodec_find_decoder(pCodecCtxOrig->codec_id);
if (pCodec == NULL) {
    fprintf(stderr, "Unsupported codec!\n");
    return -1; // Codec not found.
}
// Copy context.
pCodecCtx = avcodec_alloc_context3(pCodec);
if (avcodec_copy_context(pCodecCtx, pCodecCtxOrig) != 0) {
    fprintf(stderr, "Couldn't copy codec context");
    return -1; // Error copying codec context.
}

// Open codec.
if (avcodec_open2(pCodecCtx, pCodec, NULL) < 0) {
    return -1; // Could not open codec.
}

需要注意,我们不能直接使用视频流中的 AVCodecContext,所以我们需要用 avcodec_copy_context() 来拷贝一份新的 AVCodecContext 出来。

存储数据

接下来,我们需要一个地方来存储视频中的帧:

AVFrame *pFrame = NULL;

// Allocate video frame.
pFrame = av_frame_alloc();

由于我们计划将视频帧输出存储为 PPM 文件,而 PPM 文件是会存储为 24-bit RGB 格式的,所以我们需要将视频帧从它本来的格式转换为 RGB。FFmpeg 可以帮我们做这些。对于大多数的项目,我们可能都有将原来的视频帧转换为指定格式的需求。现在我们就来创建一个AVFrame 用于格式转换:

// Allocate an AVFrame structure.
pFrameRGB = av_frame_alloc();
if (pFrameRGB == NULL) {
    return -1;
}

尽管我们已经分配了内存类处理视频帧,当我们转格式时,我们仍然需要一块地方来存储视频帧的原始数据。我们使用 av_image_get_buffer_size 来获取需要的内存大小,然后手动分配这块内存。

int numBytes;
uint8_t *buffer = NULL;

// Determine required buffer size and allocate buffer.
numBytes = av_image_get_buffer_size(AV_PIX_FMT_RGB24, pCodecCtx->width, pCodecCtx->height, 1);
buffer = (uint8_t *) av_malloc(numBytes * sizeof(uint8_t));

av_malloc 是一个 FFmpeg 的 malloc,主要是对 malloc 做了一些封装来保证地址对齐之类的事情,它不会保证你的代码不发生内存泄漏、多次释放或其他 malloc 问题。

现在我们用 av_image_fill_arrays 函数来关联 frame 和我们刚才分配的内存。

// Assign appropriate parts of buffer to image planes in pFrameRGB Note that pFrameRGB is an AVFrame, but AVFrame is a superset of AVPicture
av_image_fill_arrays(pFrameRGB->data, pFrameRGB->linesize, buffer, AV_PIX_FMT_RGB24, pCodecCtx->width, pCodecCtx->height, 1);

现在,我们准备从视频流读取数据了。

读取数据

接下来我们要做的就是从整个视频流中读取数据包 packet,并将数据解码到我们的 frame 中,一旦获得完整的 frame,我们就转换其格式并存储它。

AVPacket packet;
int frameFinished;
struct SwsContext *sws_ctx = NULL;

// Initialize SWS context for software scaling.
sws_ctx = sws_getContext(pCodecCtx->width, pCodecCtx->height, pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, NULL, NULL, NULL);

// Read frames and save first five frames to disk.
i = 0;
while (av_read_frame(pFormatCtx, &packet) >= 0) {
    // Is this a packet from the video stream?
    if (packet.stream_index == videoStream) {
        // Decode video frame
        avcodec_decode_video2(pCodecCtx, pFrame, &frameFinished, &packet);

        // Did we get a video frame?
        if (frameFinished) {
            // Convert the image from its native format to RGB.
            sws_scale(sws_ctx, (uint8_t const * const *) pFrame->data, pFrame->linesize, 0, pCodecCtx->height, pFrameRGB->data, pFrameRGB->linesize);

            // Save the frame to disk.
            if (++i <= 5) {
                SaveFrame(pFrameRGB, pCodecCtx->width, pCodecCtx->height, i);
            }
        }
    }

    // Free the packet that was allocated by av_read_frame.
    av_packet_unref(&packet);
}

接下来的程序是比较好理解的:av_read_frame() 函数从视频流中读取一个数据包 packet,把它存储在 AVPacket 数据结构中。需要注意,我们只创建了 packet 结构,FFmpeg 则为我们填充了其中的数据,其中 packet.data 这个指针会指向这些数据,而这些数据占用的内存需要通过 av_packet_unref() 函数来释放。avcodec_decode_video2() 函数将数据包 packet 转换为视频帧 frame。但是,我们可能无法通过只解码一个 packet 就获得一个完整的视频帧 frame,可能需要读取多个 packet 才行,avcodec_decode_video2() 会在解码到完整的一帧时设置 frameFinished 为真。最后当解码到完整的一帧时,我们用 sws_scale() 函数来将视频帧本来的格式 pCodecCtx->pix_fmt 转换为 RGB。记住你可以将一个 AVFrame 指针转换为一个 AVPicture 指针。最后,我们使用我们的 SaveFrame 函数来保存这一个视频帧到文件。

SaveFrame 函数中,我们将 RGB 信息写入到一个 PPM 文件中。

void SaveFrame(AVFrame *pFrame, int width, int height, int iFrame) {
    FILE *pFile;
    char szFilename[32];
    int y;
  
    // Open file.
    sprintf(szFilename, "frame%d.ppm", iFrame);
    pFile = fopen(szFilename, "wb");
    if (pFile == NULL) {
        return;
    }
  
    // Write header.
    fprintf(pFile, "P6\n%d %d\n255\n", width, height);
  
    // Write pixel data.
    for (y = 0; y < height; y++) {
        fwrite(pFrame->data[0]+y*pFrame->linesize[0], 1, width*3, pFile);
    }
  
    // Close file.
    fclose(pFile);
}

下面我们回到 main 函数,当我们完成了视频流的读取,我们需要做一些扫尾工作:

// Free the RGB image.
av_free(buffer);
av_frame_free(&pFrameRGB);

// Free the YUV frame.
av_frame_free(&pFrame);

// Close the codecs.
avcodec_close(pCodecCtx);
avcodec_close(pCodecCtxOrig);

// Close the video file.
avformat_close_input(&pFormatCtx);

return 0;

你可以看到,这里我们用 av_free() 函数来释放我们用 av_malloc() 分配的内存。

以上便是我们这节教程的全部内容,其中的完整代码你可以从这里获得:https://github.com/samirchen/TestFFmpeg

编译执行

你可以使用下面的命令编译它:

$ gcc -o tutorial01 tutorial01.c -lavutil -lavformat -lavcodec -lswscale -lz -lm

找一个媒体文件,你可以这样执行一下试试:

$ tutorial01 myvideofile.mp4
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,454评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,553评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,921评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,648评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,770评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,950评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,090评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,817评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,275评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,592评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,724评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,409评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,052评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,815评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,043评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,503评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,627评论 2 350

推荐阅读更多精彩内容