FFmpeg Custom IO 与实况图片内嵌视频接入

FFmpeg Custom IO 与实况图片内嵌视频接入

1. 自定义 IO:format 层里普通 IO 和自定义 IO 的区别

在 FFmpeg 的 libavformat 层,demuxer 关心的不是“这是一个什么路径”,而是“我能否持续读到一段可 seek 的字节流”。

这也是普通 IO 和自定义 IO 的本质区别。

1.1 普通 IO

普通 IO 的典型调用方式是:

AVFormatContext *fmt = NULL;
avformat_open_input(&fmt, "a.mp4", NULL, NULL);
avformat_find_stream_info(fmt, NULL);

这种模式下:

  • 调用方只提供 url/path
  • FFmpeg 内部自己完成 open/read/seek
  • AVIOContext 仍然存在,但由 FFmpeg 内部创建和维护
  • fmt->pb 最终也会有值,但不是业务侧赋的
  • fmt->flags 一般不带 AVFMT_FLAG_CUSTOM_IO

可以理解成:

业务侧只描述“媒体在哪”,FFmpeg 负责“怎么读”。

1.2 自定义 IO

自定义 IO 的典型调用方式是:

unsigned char *buf = av_malloc(buf_size);
AVIOContext *avio = avio_alloc_context(
    buf,
    buf_size,
    0,
    opaque,
    read_packet,
    write_packet,
    seek
);

AVFormatContext *fmt = avformat_alloc_context();
fmt->pb = avio;
fmt->flags |= AVFMT_FLAG_CUSTOM_IO;

avformat_open_input(&fmt, NULL, NULL, NULL);
avformat_find_stream_info(fmt, NULL);

这种模式下:

  • 业务侧自己构造 AVIOContext
  • 业务侧自己定义底层 read/seek/write
  • FFmpeg 后续的 demux 行为不变,但它读到的数据入口变了

可以理解成:

业务侧不再只告诉 FFmpeg“媒体在哪”,而是直接告诉 FFmpeg“你应该怎么读这段媒体”。

需要注意的是,自定义 IO 并不等同于“只读 IO”。avio_alloc_context 同时支持读、写、seek 三类回调,因此常见组合至少有下面几种:

  • 只读输入流
avio_alloc_context(buf, buf_size, 0, opaque, read_packet, NULL, seek);
  • 只写输出流
avio_alloc_context(buf, buf_size, 1, opaque, NULL, write_packet, seek);
  • 特殊的读写双向流
avio_alloc_context(buf, buf_size, 1, opaque, read_packet, write_packet, seek);

这里第三个参数 write_flag 的语义是“这个 AVIOContext 是否允许写”。因此:

  • write_flag = 0 更适合输入型、只读型场景
  • write_flag = 1 更适合输出型,或确实需要写回调参与的场景

本文后续结合实况图片说明时,使用的是第一种:只读输入流。

1.3 这次实况图片场景里的区别

实况图片的物理文件通常是:

[ JPG/HEIC图片数据 ][ 内嵌MP4视频数据 ]
0                offset                  offset + length

如果走普通 IO:

  • avformat_open_input(&fmt, "a.jpg", ...)
  • FFmpeg 会从文件头开始读
  • 它先看到的是 JPEG/HEIC 头,而不是 MP4 头

如果走自定义 IO:

  • 业务侧把 offset ~ offset + length 这段视频区间包装成逻辑输入流
  • FFmpeg 看到的“第 0 个字节”已经是内嵌视频段的起点
  • 后续 MP4 demux、stream info、解码流程都走原生逻辑

所以这次支持实况图片,并不是修改了 FFmpeg 的 MP4 能力,而是通过 custom IO 改变了数据进入 libavformat 的方式。

2. AVIOContext:如何通过这个上下文结构体实现自定义 IO

AVIOContext 不是 demuxer,也不是 decoder。它是 libavformat 层看到的统一 IO 抽象。

对 demuxer 来说,它只关心三件事:

  • 继续读一些字节
  • 跳到某个逻辑位置
  • 可选:写一些字节

这三件事正是通过 AVIOContext 挂载的函数指针来完成的。

2.1 关键接口:avio_alloc_context

当前实现中,真正把自定义 IO 接进 FFmpeg 的地方是:

pMotionPhotoAVIO = avio_alloc_context(
    ioBuf,
    IO_BUF_SIZE,
    0,
    pIOContext,
    MotionPhotoRead,
    NULL,
    MotionPhotoSeek
);

这几个参数分别表示:

  • ioBuf
    FFmpeg IO 层使用的缓存区。
  • IO_BUF_SIZE
    上述缓存区大小,这次实现里是 32KB
  • 0
    write_flag,这里为 0 表示只读。
  • pIOContext
    用户自定义上下文,FFmpeg 不解释它,只会在回调时原样传回。
  • MotionPhotoRead
    读回调,对应 read_packet
  • NULL
    写回调,这次没实现,因为场景只需要读,不需要写。
  • MotionPhotoSeek
    seek 回调。

更完整的理解是:

  • 输入 custom IO:通常实现 read_packet + seek
  • 输出 custom IO:通常实现 write_packet,必要时再实现 seek
  • 双向或特殊协议型 IO:才可能同时实现 read_packet + write_packet + seek

从接口设计上看,avio_alloc_context 的意义就是:

把“缓冲区 + 用户上下文 + 回调函数指针”绑定成一个标准 AVIOContext

2.2 关键接口:fmt->pb

当前实现中,AVIOContext 创建完成后,会继续做下面这两步:

context->pb = pMotionPhotoAVIO;
context->flags |= AVFMT_FLAG_CUSTOM_IO;

这里做了两件事:

  • context->pb = pMotionPhotoAVIO
    AVFormatContext 的 IO 入口替换成业务侧构造的 AVIOContext
  • context->flags |= AVFMT_FLAG_CUSTOM_IO
    告诉 FFmpeg:这个 pb 不是默认 IO,而是外部自定义 IO

因此后面:

avformat_open_input(&m_context, nullptr, nullptr, &m_format_opts);

这里传 nullptr 作为输入地址是合理的,因为真正的输入来源已经不再是“路径”,而是 context->pb

2.3 关键参数:opaque

opaque 是 FFmpeg custom IO 里最重要的桥接参数。

它的特点是:

  • FFmpeg 不认识它的类型
  • FFmpeg 不关心它的内容
  • FFmpeg 只会在回调时把它原样传回

因此它最适合用来保存业务侧真正需要的上下文。

这次代码里定义了 MotionPhotoIOContext

typedef struct {
    FILE* pFile;
    int64 llDataOffset;
    int64 llDataLength;
    int64 llCurrentPos;
} MotionPhotoIOContext;

这四个字段分别表示:

  • pFile
    真实打开的外层 jpg/heic 文件句柄
  • llDataOffset
    内嵌视频区间的起始偏移
  • llDataLength
    内嵌视频区间的总长度
  • llCurrentPos
    当前逻辑流位置,范围是 [0, llDataLength]

所以对于这次实现来说,opaque 的作用就是:

把“真实文件 + 视频区间边界 + 当前逻辑位置”带进 read/seek 回调。

2.4 打开完成后还要做什么

AVIOContext 只是解决“怎么把数据喂给 FFmpeg”。
真正打开成功后,还要继续做:

avformat_open_input(...)
avformat_find_stream_info(...)

2.5 资源释放

自定义 IO 还有一个关键点是释放责任。

这次在析构/关闭路径里,针对 custom IO 做了集中释放。释放顺序是:

  • 先从 context->pb 取出 AVIOContext
  • 再从 AVIOContext->opaque 取出 MotionPhotoIOContext
  • 先关闭 pFile
  • 再释放 opaque
  • 再释放 AVIOContextbuffer
  • 最后释放 AVIOContext 本身

这也是 custom IO 场景比普通 IO 更容易踩坑的地方:
AVIOContextopaque、业务文件句柄的所有权必须统一收口,不能多处重复释放。

3. 结合实况图片:如何编写自定义 read / write / seek 回调

这次代码里真正实现了:

  • read
  • seek

没有实现 write,因为当前场景是“读取实况图片中的视频段”,不涉及写回媒体。

3.1 目标:把外层 jpg 文件伪装成逻辑 MP4 文件

实况图片的真实文件布局可以抽象成:

[ 图片数据 ][ 视频数据 ]
           ^ offset
                      ^ offset + length

业务侧希望 FFmpeg 看到的不是整个 jpg,而是:

逻辑流:
[ 视频数据 ]
0         length

因此回调需要做的,本质上是坐标映射:

  • FFmpeg 的逻辑位置 pos
  • 对应到真实文件位置 offset + pos

3.2 read 回调怎么写

这次实现的 read 回调大致如下:

int MotionPhotoRead(void* opaque, uint8_t* buf, int size)
{
    MotionPhotoIOContext* pContext = (MotionPhotoIOContext*)opaque;
    int64_t remaining = pContext->llDataLength - pContext->llCurrentPos;
    if (remaining <= 0)
    {
        return AVERROR_EOF;
    }

    int toRead = (size < remaining) ? size : (int)remaining;
    int bytesRead = fread(buf, 1, toRead, pContext->pFile);
    if (bytesRead > 0)
    {
        pContext->llCurrentPos += bytesRead;
        return bytesRead;
    }

    return AVERROR_EOF;
}

它的逻辑是:

  1. opaque 里取出 MotionPhotoIOContext
  2. 计算当前还剩多少可读数据:
remaining = llDataLength - llCurrentPos;
  1. 本次最多只能读 remaining,不能越过视频段边界
  2. fread 从当前真实文件位置读取
  3. 成功后推进 llCurrentPos
  4. 到达区间末尾时返回 AVERROR_EOF

这段逻辑的关键不是“怎么读文件”,而是“怎么限制可读边界”。

因为这保证了 FFmpeg 永远不会读到:

  • offset 之前的图片数据
  • offset + length 之后的无关数据

read 回调负责把真实文件裁剪成一个只暴露内嵌视频区间的逻辑只读流。

3.3 seek 回调怎么写

这次实现的 seek 回调大致如下:

int64_t MotionPhotoSeek(void* opaque, int64_t offset, int whence)
{
    MotionPhotoIOContext* pContext = (MotionPhotoIOContext*)opaque;
    int64_t toPos = 0;

    if (whence == SEEK_SET)
    {
        toPos = offset;
    }
    else if (whence == SEEK_CUR)
    {
        toPos = pContext->llCurrentPos + offset;
    }
    else if (whence == SEEK_END)
    {
        toPos = pContext->llDataLength + offset;
    }
    else if (whence == AVSEEK_SIZE)
    {
        return pContext->llDataLength;
    }
    else
    {
        return -1;
    }

    if (toPos < 0 || toPos > pContext->llDataLength)
    {
        return -1;
    }

    int64_t filePos = pContext->llDataOffset + toPos;
    if (fseek(pContext->pFile, filePos, SEEK_SET) != 0)
    {
        return -1;
    }

    pContext->llCurrentPos = toPos;
    return toPos;
}

它的逻辑是:

  1. 先根据 SEEK_SET / SEEK_CUR / SEEK_END 算出逻辑位置 toPos
  2. 特殊处理 AVSEEK_SIZE
return llDataLength;

这里返回的是视频区间长度,而不是整个 jpg 文件长度。

  1. 做边界检查,只允许 toPos 落在 [0, llDataLength]
  2. 映射到真实文件位置:
filePos = llDataOffset + toPos;
  1. fseek(pFile, filePos, SEEK_SET)
  2. 更新 llCurrentPos

对实况图片来说,seek 的本质不是“在 jpg 文件里 seek”,而是:

在“内嵌视频逻辑空间”里 seek,再翻译成真实文件偏移。

这正是 AVIOContext 能把一个大文件中的子区间伪装成独立媒体文件的核心原因。

4. 总结

这次实况图片支持里,FFmpeg 相关的核心改动可以总结成三句话:

  1. 普通 IO 和自定义 IO 的区别,不在 demux 逻辑,而在数据进入 libavformat 的入口。
  2. AVIOContextlibavformat 的统一 IO 抽象,自定义 IO 的本质是业务侧接管它的 read/seek/write 行为。
  3. 在实况图片场景中,通过 MotionPhotoIOContext + MotionPhotoRead + MotionPhotoSeek,把 jpg/heic 文件中的内嵌视频区间虚拟成一个独立的逻辑 MP4 输入流,从而复用 FFmpeg 原生的 MP4 解复用与解码流程。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值