Libav(ffmpeg)将解码视频时间戳复制到编码器

12
我正在编写一个应用程序,从输入文件(任何编解码器、任何容器)中解码单个视频流,进行一系列图像处理,并将结果编码到输出文件(单个视频流、Quicktime RLE、MOV)中。我使用的是ffmpeg的libav 3.1.5(目前是Windows版本,但该应用程序将跨平台)。
输入和输出帧之间存在1:1的对应关系,我希望输出的帧定时与输入相同。但我非常困难地实现了这一点。因此,我的一般问题是:如何可靠地(在所有输入情况下)设置输出帧时间与输入相同? 我花了很长时间才通过API并达到我现在的程度。我组合了一个最小的测试程序来使用:
#include <cstdio>

extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
}

using namespace std;


struct DecoderStuff {
    AVFormatContext *formatx;
    int nstream;
    AVCodec *codec;
    AVStream *stream;
    AVCodecContext *codecx;
    AVFrame *rawframe;
    AVFrame *rgbframe;
    SwsContext *swsx;
};


struct EncoderStuff {
    AVFormatContext *formatx;
    AVCodec *codec;
    AVStream *stream;
    AVCodecContext *codecx;
};


template <typename T>
static void dump_timebase (const char *what, const T *o) {
    if (o)
        printf("%s timebase: %d/%d\n", what, o->time_base.num, o->time_base.den);
    else
        printf("%s timebase: null object\n", what);
}


// reads next frame into d.rawframe and d.rgbframe. returns false on error/eof.
static bool read_frame (DecoderStuff &d) {

    AVPacket packet;
    int err = 0, haveframe = 0;

    // read
    while (!haveframe && err >= 0 && ((err = av_read_frame(d.formatx, &packet)) >= 0)) {
       if (packet.stream_index == d.nstream) {
           err = avcodec_decode_video2(d.codecx, d.rawframe, &haveframe, &packet);
       }
       av_packet_unref(&packet);
    }

    // error output
    if (!haveframe && err != AVERROR_EOF) {
        char buf[500];
        av_strerror(err, buf, sizeof(buf) - 1);
        buf[499] = 0;
        printf("read_frame: %s\n", buf);
    }

    // convert to rgb
    if (haveframe) {
        sws_scale(d.swsx, d.rawframe->data, d.rawframe->linesize, 0, d.rawframe->height,
                  d.rgbframe->data, d.rgbframe->linesize);
    }

    return haveframe;

}


// writes an output frame, returns false on error.
static bool write_frame (EncoderStuff &e, AVFrame *inframe) {

    // see note in so post about outframe here
    AVFrame *outframe = av_frame_alloc();
    outframe->format = inframe->format;
    outframe->width = inframe->width;
    outframe->height = inframe->height;
    av_image_alloc(outframe->data, outframe->linesize, outframe->width, outframe->height,
                   AV_PIX_FMT_RGB24, 1);
    //av_frame_copy(outframe, inframe);
    static int count = 0;
    for (int n = 0; n < outframe->width * outframe->height; ++ n) {
        outframe->data[0][n*3+0] = ((n+count) % 100) ? 0 : 255;
        outframe->data[0][n*3+1] = ((n+count) % 100) ? 0 : 255;
        outframe->data[0][n*3+2] = ((n+count) % 100) ? 0 : 255;
    }
    ++ count;

    AVPacket packet;
    av_init_packet(&packet);
    packet.size = 0;
    packet.data = NULL;

    int err, havepacket = 0;
    if ((err = avcodec_encode_video2(e.codecx, &packet, outframe, &havepacket)) >= 0 && havepacket) {
        packet.stream_index = e.stream->index;
        err = av_interleaved_write_frame(e.formatx, &packet);
    }

    if (err < 0) {
        char buf[500];
        av_strerror(err, buf, sizeof(buf) - 1);
        buf[499] = 0;
        printf("write_frame: %s\n", buf);
    }

    av_packet_unref(&packet);
    av_freep(&outframe->data[0]);
    av_frame_free(&outframe);

    return err >= 0;

}


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

    const char *infile = "wildlife.wmv";
    const char *outfile = "test.mov";
    DecoderStuff d = {};
    EncoderStuff e = {};

    av_register_all();

    // decoder
    avformat_open_input(&d.formatx, infile, NULL, NULL);
    avformat_find_stream_info(d.formatx, NULL);
    d.nstream = av_find_best_stream(d.formatx, AVMEDIA_TYPE_VIDEO, -1, -1, &d.codec, 0);
    d.stream = d.formatx->streams[d.nstream];
    d.codecx = avcodec_alloc_context3(d.codec);
    avcodec_parameters_to_context(d.codecx, d.stream->codecpar);
    avcodec_open2(d.codecx, NULL, NULL);
    d.rawframe = av_frame_alloc();
    d.rgbframe = av_frame_alloc();
    d.rgbframe->format = AV_PIX_FMT_RGB24;
    d.rgbframe->width = d.codecx->width;
    d.rgbframe->height = d.codecx->height;
    av_frame_get_buffer(d.rgbframe, 1);
    d.swsx = sws_getContext(d.codecx->width, d.codecx->height, d.codecx->pix_fmt,
                            d.codecx->width, d.codecx->height, AV_PIX_FMT_RGB24,
                            SWS_POINT, NULL, NULL, NULL);
    //av_dump_format(d.formatx, 0, infile, 0);
    dump_timebase("in stream", d.stream);
    dump_timebase("in stream:codec", d.stream->codec); // note: deprecated
    dump_timebase("in codec", d.codecx);

    // encoder
    avformat_alloc_output_context2(&e.formatx, NULL, NULL, outfile);
    e.codec = avcodec_find_encoder(AV_CODEC_ID_QTRLE);
    e.stream = avformat_new_stream(e.formatx, e.codec);
    e.codecx = avcodec_alloc_context3(e.codec);
    e.codecx->bit_rate = 4000000; // arbitrary for qtrle
    e.codecx->width = d.codecx->width;
    e.codecx->height = d.codecx->height;
    e.codecx->gop_size = 30; // 99% sure this is arbitrary for qtrle
    e.codecx->pix_fmt = AV_PIX_FMT_RGB24;
    e.codecx->time_base = d.stream->time_base; // ???
    e.codecx->flags |= (e.formatx->flags & AVFMT_GLOBALHEADER) ? AV_CODEC_FLAG_GLOBAL_HEADER : 0;
    avcodec_open2(e.codecx, NULL, NULL);
    avcodec_parameters_from_context(e.stream->codecpar, e.codecx); 
    //av_dump_format(e.formatx, 0, outfile, 1);
    dump_timebase("out stream", e.stream);
    dump_timebase("out stream:codec", e.stream->codec); // note: deprecated
    dump_timebase("out codec", e.codecx);

    // open file and write header
    avio_open(&e.formatx->pb, outfile, AVIO_FLAG_WRITE); 
    avformat_write_header(e.formatx, NULL);

    // frames
    while (read_frame(d) && write_frame(e, d.rgbframe))
        ;

    // write trailer and close file
    av_write_trailer(e.formatx);
    avio_closep(&e.formatx->pb); 

}

关于此事,有几点需要注意:

  • 由于我迄今为止所有关于帧计时的尝试都失败了,因此我已经从这段代码中删除了几乎所有与计时有关的内容,以便重新开始。
  • 出于简洁考虑,几乎所有的错误检查和清理都被省略了。
  • 我在write_frame中为什么要分配一个新的输出帧和一个新的缓冲区,而不是直接使用inframe,是因为这更能代表我的实际应用程序正在做的事情。我的真实应用程序也在内部使用RGB24,因此需要进行转换。
  • 我在outframe中生成奇怪的模式,而不是使用av_copy_frame之类的东西,是因为我只想要一个能够与Quicktime RLE良好压缩的测试模式(否则我的测试输入最终会生成1.7GB的输出文件)。
  • 我使用的输入视频“wildlife.wmv”可以在这里找到。我已经硬编码了文件名。
  • 我知道avcodec_decode_video2avcodec_encode_video2已经被弃用,但我不在意。它们很好用,我已经花费了太多时间来理解最新版本的API,ffmpeg几乎每次发布都会更改他们的API,而我现在真的不想处理avcodec_send_*avcodec_receive_*
  • 我认为我应该通过avcodec_encode_video2传递一个空帧来刷新一些缓冲区之类的东西,但我对此有点困惑。除非有人愿意解释这个问题,否则让我们暂时忽略它,因为它是一个单独的问题。文档对这个问题像对其他所有问题一样模糊。
  • 我的测试输入文件的帧速率为29.97。

现在,关于我的当前尝试。上述代码中存在以下与时间相关的字段,在粗体中详细说明/混淆。由于API非常复杂,所以有很多这样的字段:

  • main: d.stream->time_base: 输入视频流时间基准。 对于我的测试输入文件,这是1/1000。
  • main: d.stream->codec->time_base: 不确定这是什么(我从来没弄明白为什么AVStream有一个AVCodecContext字段,而你总是使用自己的新上下文),并且codec字段也已被弃用。对于我的测试输入文件,这是1/1000。
  • main: d.codecx->time_base: 输入编解码器上下文时间基准。 对于我的测试输入文件,这是0/1。我应该设置吗?
  • main: e.stream->time_base: 我创建的输出流的时间基准。 我应该将其设置为什么?
  • main: e.stream->codec->time_base: 输出流的弃用和神秘编解码器字段的时间基准。 我需要将其设置为任何值吗?
  • main: e.codecx->time_base: 我创建的编码器上下文的时间基准。 我应该将其设置为什么?
  • read_frame: packet.dts: 读取的数据包的解码时间戳。
  • read_frame: packet.pts: 读取的数据包的显示时间戳。
  • read_frame: packet.duration: 读取的数据包的持续时间。
  • read_frame: d.rawframe->pts: 解码的原始帧的显示时间戳。 这总是0。为什么解码器没有读取它呢...?
  • read_frame: d.rgbframe->pts / write_frame: inframe->pts: 转换为RGB的解码帧的显示时间戳。当前未设置任何值。
  • read_frame: d.rawframe->pkt_*: 从数据包复制的字段,在阅读this post后发现。它们被正确设置,但我不知道它们是否有用。
  • write_frame: outframe->pts: 正在编码的帧的显示时间戳。 我应该将其设置为什么?
  • write_frame: outframe->pkt_*: 来自数据包的定时字段。 我应该设置它们吗?编码器似乎忽略了它们。
  • write_frame: packet.dts: 正在编码的数据包的解码时间戳。 我应该将其设置为什么?
  • write_frame: packet.pts: 正在编码的数据包的显示时间戳。 我应该将其设置为什么?
  • write_frame: packet.duration: 正在编码的数据包的持续时间。 我应该将其设置为什么?
我尝试了以下操作,并得到了描述的结果。请注意,inframed.rgbframe
  1.  
    • 初始化e.stream->time_base = d.stream->time_base
    • 初始化e.codecx->time_base = d.codecx->time_base
    • read_frame中设置d.rgbframe->pts = packet.dts
    • write_frame中设置outframe->pts = inframe->pts
    • 结果:发出警告,提示编码器时间基数未设置(由于d.codecx->time_base为0/1),导致段错误。
  2.  
    • 初始化e.stream->time_base = d.stream->time_base
    • 初始化e.codecx->time_base = d.stream->time_base
    • read_frame中设置d.rgbframe->pts = packet.dts
    • write_frame中设置outframe->pts = inframe->pts
    • 结果:没有警告,但是VLC报告帧速率为480.048(不知道这个数字来自哪里),文件播放过快。此外,编码器将packet中的所有时序字段设置为0,这不是我期望的。(编辑:事实证明,这是因为av_interleaved_write_frameav_write_frame不同,它接管了数据包并将其与空白数据包进行交换,而我是在该调用之后打印这些值的。因此它们不被忽略。)
  3.  
    • 初始化e.stream->time_base = d.stream->time_base
    • 初始化e.codecx->time_base = d.stream->time_base
    • read_frame中设置d.rgbframe->pts = packet.dts
    • write_frame中设置packet中的任何一个pts/dts/duration为任何值。
    • 结果:警告未设置包时间戳。编码器似乎重置所有数据包时间字段为0,因此这些都没有任何影响。
  4.  
    • 初始化e.stream->time_base = d.stream->time_base
    • 初始化e.codecx->time_base = d.stream->time_base
    • 在阅读this post后,我发现AVFrame中有这些字段:pkt_ptspkt_dtspkt_duration,因此我尝试将这些字段全部复制到outframe中。
    • 结果:真的很有希望,但最终结果与第三次尝试相同(未设置数据包时间戳警告,结果不正确)。
我尝试了上述各种手工排列组合,但都没有奏效。我想做的是创建一个输出文件,其播放时间和帧速率与输入文件相同(在这种情况下为29.97恒定帧速率)。
那么我该怎么做呢?在这里有数不清的与时间相关的字段,我该如何使输出与输入相同?而且我要如何以处理任意视频输入格式,这些格式可能将它们的时间戳和时间基准存储在不同的位置?我需要这始终可行。

为了参考,这里是我测试输入文件的视频流中读取的所有数据包和帧时间戳的表格,以便了解我的测试文件的情况。没有设置任何输入数据包pts,同样适用于帧pts,并且由于某种原因,前108帧的持续时间为0。VLC可以正常播放该文件,并将帧速率报告为29.9700089:


@halfer 哈,我刚回来也要加上赏金奖励。 - Jason C
1
啊,没关系,Jason。我认为这个答案(和问题)都非常好。 - halfer
2个回答

20

我认为您在这里遇到的问题是时间基准,一开始可能有些令人困惑。

  • d.stream->time_base: 输入视频流时间基准。这是输入容器中时间戳的分辨率。从 av_read_frame 返回的编码帧将具有该分辨率的时间戳。
  • d.stream->codec->time_base: 不确定这是什么。这是旧的API,保留在这里以实现API兼容性;由于您正在使用编解码器参数,因此请忽略它。
  • d.codecx->time_base: 输入编解码器上下文时间基准。对于我的测试输入文件,这是0/1。我应该设置它吗? 这是编解码器时间戳的分辨率(与容器相反)。编解码器将假定其输入编码帧的时间戳具有此分辨率,并且还将在输出解码帧中设置此分辨率的时间戳。
  • e.stream->time_base: 我创建的输出流时间基准。与解码器相同
  • e.stream->codec->time_base。与复用器相同-忽略这个。
  • e.codecx->time_base - 与复用器相同

所以您需要执行以下操作:

  • 打开解复用器。这部分工作正常。
  • 将解码器时间基准设置为某个“合理”的值,因为解码器可能不会这样做,而0/1是不好的。如果任何组件的任何时间基准未设置,则事情将无法按预期工作。最简单的方法是只复制解复用器的时间基准。
  • 打开解码器。它可能会更改其时间基准,也可能不会。
  • 设置编码器时间基准。最简单的方法是从(现在已打开的)解码器复制时间基准,因为您没有更改帧速率或其他内容。
  • 打开编码器。它可能会更改其时间基准。
  • 设置复用器时间基准。同样,最简单的方法是从编码器复制时间基准。
  • 打开复用器。它可能也会更改其时间基准。

现在针对每一帧进行以下操作:

  • 从混流器中读取它
  • 将时间戳从混流器转换为解码器时间基。可以使用av_packet_rescale_ts函数来协助完成这个过程
  • 对数据包进行解码
  • 设置帧的时间戳(pts),使用av_frame_get_best_effort_timestamp返回的值
  • 将帧时间戳从解码器时间基转换为编码器时间基。可以使用av_rescale_q或者av_rescale_q_rnd
  • 对数据包进行编码
  • 将时间戳从编码器转换为混流器时间基。同样可以使用av_packet_rescale_ts

这些操作可能有些繁琐,特别是如果编码器不会在打开时改变它的时间基(那么你就不需要转换原始帧的pts)。


关于flushing - 传递给编码器的帧并不一定会立即编码和输出,因此,确实需要调用avcodec_encode_video2函数,并将帧设置为空来告诉编码器你已经完成了所有编码工作,并让它输出所有剩余的数据(你需要像处理其他数据包一样将其传递给混流器)。实际上,你需要重复这个过程直到编码器停止输出数据包。可以在ffmpeg的doc/examples文件夹中查看一个编码示例来了解更多信息。


太棒了。那么对于编码方面,我已经从这个答案中解决了问题:在编码器端,我将e.stream->time_base = d.stream->time_base设置为一个初始的合理值,然后avformat_write_header可能会根据需要更改它。我将e.codecx->time_base设置为任何合理的值(我使用{1,1000}),我没有意识到这是我的选择,这是一个重要的缺失。然后,在编码时,我将packet的pts/dts设置为inframe的pkt_pts和pkt_dts,不设置持续时间,然后让av_packet_rescale_ts完成魔术。现在它正在正确地工作。现在剩下的问题是... - Jason C
在我的测试输入流中,所有的输入包dts都被设置了,但pts没有。如果我直接将它们复制到输出包中,编码器会给出一个警告:“数据包时间戳未设置,这将在未来停止工作”,因为pts未设置。那么生成输出pts/dts最稳健的方法是什么?我发现像if (pts == AV_NOPTS_VALUE) pts = dts这样的方法适用于流,但这真的是最好的方法吗?PS感谢刷新提示。PPS确认一下,在avcodec_open2之前,我应该将d.codecx->time_base设置为任何合理的值,以防万一? - Jason C
2
有一件事我忘了提,就是在解码帧之后需要执行 frame->pts = av_frame_get_best_effort_timestamp(frame);。不要设置输出包的时间戳 - 编码器应该根据其输入帧的 pts 值为您设置时间戳。是的,在 avcodec_open2 之前设置解码器时间基。 - Andrey Turkin
2
这真的是一个极好的回答,大部分内容都超出了我的理解范围。感谢您发布它,请继续发布更多相同的内容!我已经加了 +100 鼓励分,如果积分对您有意义的话。 - halfer

8

非常感谢Andrey Turkin的清晰明了、有益的答案,让我成功地解决了这个问题。现在,我想分享一下我所做的具体事情:

在初始化过程中,要理解libav可能会在任何时候更改这些初始时间基准:

  • Initialize decoder codec context time base to something reasonable immediately after allocating codec context. I went for sub-millisecond resolution:

    d.codecx->time_base = { 1, 10000 };
    
  • Initialize encoder stream time base immediately after creating the new stream (note: in the QtRLE case, if I leave this {0,0}, it'll be set by the encoder to {0,90000} after writing the header, but I don't know if other situations will be as cooperative, so I initialize it here). At this point it's safe to just copy from the input stream, although I noticed I can also initialize it arbitrarily (e.g. {1,10000}) and it will still work later:

    e.stream->time_base = d.stream->time_base;
    
  • Initialize encoder codec context time base immediately after allocating it. Same deal as stream time base as far as copying from decoder:

    e.codecx->time_base = d.codecx->time_base;
    

我之前没有意识到的一件事是,我可以设置这些时间戳,而且libav会遵守我的设置。没有任何约束,一切由我掌控,无论我将解码后的时间戳设置为何种时间基准,libav都会按照我选择的时间基准来设置。

然后,在解码时:

  • All I have to do is fill in the decoded frames pts manually. The pkt_* fields are ignorable:

    d.rawframe->pts = av_frame_get_best_effort_timestamp(d.rawframe);
    
  • And since I'm converting formats I also copy it to the converted frame:

    d.rgbframe->pts = d.rawframe->pts;
    

然后,编码:

  • Only the frame's pts needs to be set. Libav will deal with the packet. So just prior to encoding frame:

    outframe->pts = inframe->pts;
    
  • However, I still have to manually convert packet timestamps, which seems strange, but all of this is pretty strange so I guess it's par for the course. The frame timestamp is still in the decoder stream time base, so after encoding the frame but just before writing the packet:

    av_packet_rescale_ts(&packet, d.stream->time_base, e.stream->time_base);
    

它运行得非常好,大多数情况下:我注意到VLC报告的输入是29.97 FPS,但输出却是30.03 FPS,我无法完全弄清楚。但是,在我测试过的所有媒体播放器中,一切似乎都很正常。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接