Skip to main content

3 posts tagged with "c"

View All Tags

FFmpeg是如何解码图像的-修复JXL解码assertion

· 6 min read
Jack Lau
Blog Author

用户反馈使用mpv播放crop JXL图像的时候遇到assertion

[ffmpeg] Probing jpegxl_pipe score:98 size:20
[ffmpeg/demuxer] jpegxl_pipe: Before avformat_find_stream_info() pos: 0 bytes read:0 seeks:0 nb_streams:1
[ffmpeg/video] libjxl: BASIC_INFO event emitted
[ffmpeg/video] libjxl: COLOR_ENCODING event emitted
[ffmpeg/video] libjxl: FRAME event emitted
[ffmpeg/video] libjxl: NEED_IMAGE_OUT_BUFFER event emitted
[ffmpeg/video] libjxl: FULL_IMAGE event emitted
[ffmpeg/demuxer] jpegxl_pipe: stream 0: start_time: NOPTS duration: NOPTS
[ffmpeg/demuxer] jpegxl_pipe: format: start_time: NOPTS duration: NOPTS (estimate from bit rate) bitrate=0 kb/s
[ffmpeg/demuxer] jpegxl_pipe: After avformat_find_stream_info() pos: 20 bytes read:20 seeks:0 frames:1
● Image --vid=1 (jpegxl 200x200)
[ffmpeg] detected 28 logical cores
[ffmpeg/video] libjxl: BASIC_INFO event emitted
[ffmpeg/video] libjxl: COLOR_ENCODING event emitted
[ffmpeg/video] libjxl: FRAME event emitted
[ffmpeg/video] libjxl: NEED_IMAGE_OUT_BUFFER event emitted
[ffmpeg/video] libjxl: FULL_IMAGE event emitted
[ffmpeg/video] libjxl: frame->private_ref: 0x558c555c8140
VO: [gpu-next] 200x200 gray
[ffmpeg/video] libjxl: SUCCESS event emitted
[ffmpeg/video] libjxl: frame->private_ref: (nil)
[ffmpeg] Assertion frame->private_ref || !(avctx->codec->capabilities & (1 << 1)) failed at src/libavcodec/decode.c:684

这是复现流程:

convert -size 200x200 xc:black image.png
cjxl -d 0 image.{png,jxl}

mkdir config
echo 'C vf toggle crop=in_w:in_w/2.4' > config/input.conf
mpv --config-dir=config image.jxl
<shift-c>

用户创建一张JXL图像,然后使用mpv播放,在播放过程中切换crop滤镜,就会触发assertion。

深入分析这个问题之前,我们先简单介绍一下FFmpeg解码的关键流程以及MPV的播放流程

FFmpeg解码一帧图像的流程

省略一些初始化的流程,我们直接介绍最核心的两个API:

  • avcodec_send_packet: 向编解码器发送一个packet,一个packet可以包含一帧或多帧数据
  • avcodec_receive_frame :从编解码器接收一帧图像数据

在这里需要先介绍一个概念,很多情况下一个packet里的数据不足以解码出完整的图像,所以会有一个内部缓冲区,直到收到足够多的packet或者一个空pkt表示EOF,才会输出

MPV的播放流程

有一个关键的函数lavc_process包含了解码流程

void lavc_process(struct mp_filter *f, struct lavc_state *state,
int (*send)(struct mp_filter *f, struct demux_packet *pkt),
int (*receive)(struct mp_filter *f, struct mp_frame *res))
{
if (!mp_pin_in_needs_data(f->ppins[1]))
return;

struct mp_frame frame = {0};
int ret_recv = receive(f, &frame);
if (frame.type) {
state->eof_returned = false;
mp_pin_in_write(f->ppins[1], frame);
} else if (ret_recv == AVERROR_EOF) {
if (!state->eof_returned)
mp_pin_in_write(f->ppins[1], MP_EOF_FRAME);
state->eof_returned = true;
state->packets_sent = false;
} else if (ret_recv == AVERROR(EAGAIN)) {
// Need to feed a packet.
frame = mp_pin_out_read(f->ppins[0]);
struct demux_packet *pkt = NULL;
if (frame.type == MP_FRAME_PACKET) {
pkt = frame.data;
} else if (frame.type != MP_FRAME_EOF) {
if (frame.type) {
MP_ERR(f, "unexpected frame type\n");
mp_frame_unref(&frame);
mp_filter_internal_mark_failed(f);
}
return;
} else if (!state->packets_sent) {
// EOF only; just return it, without requiring send/receive to
// pass it through properly.
mp_pin_in_write(f->ppins[1], MP_EOF_FRAME);
return;
}
int ret_send = send(f, pkt);
if (ret_send == AVERROR(EAGAIN)) {
// Should never happen, but can happen with broken decoders.
MP_WARN(f, "could not consume packet\n");
mp_pin_out_unread(f->ppins[0], frame);
mp_filter_wakeup(f);
return;
}
state->packets_sent = true;
demux_packet_pool_push(f->packet_pool, pkt);
mp_filter_internal_mark_progress(f);
} else {
// Decoding error, or hwdec fallback recovery. Just try again.
mp_filter_internal_mark_progress(f);
}
}

对于JXL图像解码,简单来说,这个函数的解码流程是:

  1. avcodec_receive_frame尝试从解码器接收数据 由于还没发送packet,所以自然收不到frame,错误码是EAGAIN
  2. avcodec_send_packet 发送第一个packet
  3. avcodec_receive_frame 尝试从解码器接收数据,仍然为EAGAIN 这是因为需要我们再发送一个空packet表示EOF(没有更多数据了)
  4. avcodec_send_packet 发送空packet
  5. avcodec_receive_frame 成功接收到解码后的数据

这个正常的解码流程mpv是没有问题的,问题出在我们按下了shift-c切换滤镜,出现了assertion,让我们再看下具体日志:

[ffmpeg/video] libjxl: BASIC_INFO event emitted
[ffmpeg/video] libjxl: COLOR_ENCODING event emitted
[ffmpeg/video] libjxl: FRAME event emitted
[ffmpeg/video] libjxl: NEED_IMAGE_OUT_BUFFER event emitted
[ffmpeg/video] libjxl: FULL_IMAGE event emitted
[ffmpeg/video] libjxl: frame->private_ref: 0x558c555c8140
VO: [gpu-next] 200x200 gray
[ffmpeg/video] libjxl: SUCCESS event emitted
[ffmpeg/video] libjxl: frame->private_ref: (nil)
[ffmpeg] Assertion frame->private_ref || !(avctx->codec->capabilities & (1 << 1)) failed at src/libavcodec/decode.c:684

可以看到SUCCESS event emitted是按下shift-c切换滤镜后触发的,然后frame->private_ref就是null了,触发assertion

debug mpv代码得知,在切换滤镜后,mpv会flush codec buffer并seek一下,相当于重新解码一次图像,但是复用了原有解码上下文,就是这第二次解码图像出现了问题。

日志上显示SUCCESS event emitted,这个SUCCESS event对于JXL解码来说意味着解码完成,但是为什么这个事件触发在切换滤镜后,而不是在第一次解码完成后?

如果我们使用ffplay单独播放一张JXL图像可以看到

[libjxl @ 0x1368087d0] BASIC_INFO event emitted
[libjxl @ 0x1368087d0] COLOR_ENCODING event emitted
[libjxl @ 0x1368087d0] FRAME event emitted
[libjxl @ 0x1368087d0] NEED_IMAGE_OUT_BUFFER event emitted
[libjxl @ 0x1368087d0] FULL_IMAGE event emitted

正常情况下是不会看到success事件的,这是因为libjxldec代码在frame_complete后手动处理了“善后”工作

                } else if (ctx->frame_complete) {
libjxl_finalize_frame(avctx, frame, ctx->frame);
ctx->jret = JXL_DEC_SUCCESS;
return 0;
}

但是忘记reset了decoder状态,导致我们在切换滤镜后,重新发了一遍相同的packet,解码器自然认为已经解码完成了,就直接触发了SUCCESS事件,没有对其重新解码,而且mpv flush了codec buffer,所以frame里面没有数据,frame->private_ref自然也为空,就触发了assertion

        case JXL_DEC_SUCCESS:
av_log(avctx, AV_LOG_DEBUG, "SUCCESS event emitted\n");
/*
* this event will be fired when the zero-length EOF
* packet is sent to the decoder by the client,
* but it will also be fired when the next image of
* an image2pipe sequence is loaded up
*/
libjxl_finalize_frame(avctx, frame, ctx->frame);
JxlDecoderReset(ctx->decoder);
libjxl_init_jxl_decoder(avctx);
return 0;

所以解决方案也很简单,就是把手动“善后”工作goto到JXL_DEC_SUCCESS事件处理中

修复patch已合并 https://code.ffmpeg.org/FFmpeg/FFmpeg/commit/13c91c97d12a28750f572c87cf13934456845df1

FFmpeg是如何探测文件格式的-增强AMR探测

· 5 min read
Jack Lau
Blog Author

FFmpeg可能是世界上能识别最多媒体文件格式的软件,但它到底是如何进行文件格式探测呢?

首先,每种文件格式或数据包从二进制的角度上理解,都有其独特的数据结构定义

通常很多数据包都是header+payload的结构,header定义了一些元数据(可能是帧率,码率,分辨率等),payload则包含了实际的数据内容

大多数数据包只需要解析header就足以判断它的格式是什么

拿RTP数据包为例,参考RFC 3550,其header结构如下:

 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P|X| CC |M| PT | sequence number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| timestamp |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| synchronization source (SSRC) identifier |
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
| contributing source (CSRC) identifiers |
| .... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

可以看到header中定义了许多元数据,比如版本号、是否有扩展头、负载类型、序列号等。

对每一种文件格式或数据包做数据结构定义的好处,除了存储文件解码所需的数据,更重要的是这种统一的数据结构定义,便于不同的软件(比如ffmpeg)对其进行解析和处理。

所以,FFmpeg就可以根据不同数据包的结构定义,写出对应的probe函数。

但是文件格式实在太多了,FFmpeg难免会有一些识别错误的情况

最近有用户反馈一个bug,提供了一个m3u文件:

#EXTM3U
./aaaaaaa.00000..aaa
./aaaaaaa.00000..aaa
...

是由这行命令生成的

#EXTM3U
(echo '#EXTM3U'; for i in $(seq 1 102); do echo './aaaaaaa.00000..aaa'; done) >test.m3u

这个文件用于mpv的播放列表,但FFmpeg错误的将其识别为AMR文件

Input #0, amrnb, from 'test.m3u':
Duration: 00:00:03.31, bitrate: 5 kb/s
Stream #0:0, 50, 1/8000: Audio: amr_nb (amrnb), 8000 Hz, mono, fltp, 5 kb/s
[AVIOContext @ 0x5563a8503140] Statistics: 2150 bytes read, 0 seeks

在深入分析probe代码之前,参考RFC 4867, 我们先简单了解一下amr:

  1. AMR (Adaptive Multi-Rate)是一种音频编码格式,用于移动网络传输,可以动态调整码率
  2. amr有两种存储形式:3GPP(有header)和raw数据(无header),在本例中识别到的是raw 的 amrnb,因此我们只讨论raw格式
  3. amrnb是窄带(Narrowband), amrwb是宽带(Wideband)
  4. 在raw amr数据中,每一帧都有一个ToC(Table of Contents),可以理解为每一帧的header

让我们深入分析一下amrnb的probe函数:

static int amrnb_probe(const AVProbeData *p)
{
int mode, i = 0, valid = 0, invalid = 0;
const uint8_t *b = p->buf;

while (i < p->buf_size) {
mode = b[i] >> 3 & 0x0F;
if (mode < 9 && (b[i] & 0x4) == 0x4) {
int last = b[i];
int size = amrnb_packed_size[mode];
while (size--) {
if (b[++i] != last)
break;
}
if (size > 0) {
valid++;
i += size;
}
} else {
valid = 0;
invalid++;
i++;
}
}
if (valid > 100 && valid >> 4 > invalid)
return AVPROBE_SCORE_EXTENSION / 2 + 1;
return 0;
}

由于raw amr数据没有header,只有payload,所以第一个字节就是第一帧的ToC(Table of Contents),相当于每一帧的header

让我们看一下amrnb ToC的数据结构:

  0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|F| FT |Q|P|P|
+-+-+-+-+-+-+-+-+
  • F 如果为0,表示最后一帧
  • FT 是 Frame type,表示帧类型(不同类型对应不同码率,对应不同帧大小)
  • Q 是Frame quality,如果为0,表示帧损坏
  • 两个 P 是 padding bits,必须为0

probe函数中核心逻辑如下

    if (mode < 9 && (b[i] & 0x4) == 0x4) {
  1. ffmpeg检查了ToC中的FTQ字段 ,如果符合要求,就认为是一个有效帧,
int size = amrnb_packed_size[mode];
  1. 然后通过帧类型获取到该帧的大小,跳过该帧,继续检查下一帧。
    if (valid > 100 && valid >> 4 > invalid)
return AVPROBE_SCORE_EXTENSION / 2 + 1;
  1. 如果有效帧数量超过100个并且有效帧数量是无效帧数量的16倍以上,就认为是amrnb文件

清楚了probe逻辑后再来看m3u文件

#EXTM3U
./aaaaaaa.00000..aaa
./aaaaaaa.00000..aaa
...
  1. 先检查第一个字节 #(0010 0011)不是有效帧,跳过第一个字节
  2. 检查第二个字节 E(0100 0101), FT解析为8,Q解析为1,符合要求,认为是一个有效帧,根据FT作为index获取该帧大小是6,跳过6个字节
  3. 检查第7(1 + 6)个字节 . (0010 1110),FT解析为5,Q解析为1,符合要求,认为是一个有效帧,根据FT获取该帧大小是20,跳过20个字节
  4. 检查第27(1 + 6 + 20)个字节 仍然是 .于是重复第三步,将所有./aaaaaaa.00000..aaa识别为有效帧,最后valid=102, invalid=1,符合要求,认为是amrnb文件

根据FT字段,获取到帧大小的列表: amrnb_packed_size[16] = { 13, 14, 16, 18, 20, 21, 27, 32, 6, 1, 1, 1, 1, 1, 1, 1 };

这个m3u的数据恰到好处,导致其蒙混过关了,想解决这个问题需要仔细看amrnb数据格式的定义,发现FFmpeg目前只分析了FTQ字段,而没有分析FP字段,其中F是0或1均为有效帧,但P作为padding bits,必须为0,而上面误判的两个字节E(0100 0101)和.(0010 1110)的P字段不符合全0的定义,所以我们只需要增加对P字段的检查即可

修复patch已合并ffmpeg master

https://code.ffmpeg.org/FFmpeg/FFmpeg/commit/ec0173ab59e9927a27a959c8c4706cd5316d0560