nextlib 项目架构与深度技术指南 (Architecture & Technical Master Guide)

📦 项目地址 : github.com/anilbeesett...

这份文档旨在为开发者提供 nextlib 的全景视图,涵盖模块设计、FFmpeg 原生层深度原理、关键渲染流程及开发注意事项。


目录 (Table of Contents)

  1. 核心架构概览
  2. 模块详解
    • [A. media3ext 模块 (核心解码与播放)](#A. media3ext 模块 (核心解码与播放) "#a-media3ext-%E6%A8%A1%E5%9D%97%E6%A0%B8%E5%BF%83%E8%A7%A3%E7%A0%81%E4%B8%8E%E6%92%AD%E6%94%BE")
    • [B. mediainfo 模块 (媒体分析与帧提取)](#B. mediainfo 模块 (媒体分析与帧提取) "#b-mediainfo-%E6%A8%A1%E5%9D%97%E5%AA%92%E4%BD%93%E5%88%86%E6%9E%90%E4%B8%8E%E5%B8%A7%E6%8F%90%E5%8F%96")
  3. [FFmpeg 核心库深度解析](#FFmpeg 核心库深度解析 "#3-ffmpeg-%E6%A0%B8%E5%BF%83%E5%BA%93%E6%B7%B1%E5%BA%A6%E8%A7%A3%E6%9E%90-native-libraries-deep-dive")
  4. 构建系统与依赖管理
  5. 关键技术笔记与原理说明
  6. 核心流程示例
  7. 开发避坑指南
  8. 音视频开发基础知识
  9. 常见问题与调试技巧

1. 核心架构概览

nextlib 是一个基于 FFmpeg 增强 Android Media3 (ExoPlayer) 功能的库,填补了原生 Media3 在格式兼容性(如无损音频、视频软解支持)上的空白。

模块拓扑 (Module Topology)

graph TD App[应用层 App Layer] --> mediainfo[mediainfo 模块] App --> media3ext[media3ext 模块] subgraph mediainfo [媒体分析与缩略图 Media Analysis & Thumbnail] MI[MediaInfo.kt] --> MTR[MediaThumbnailRetriever.kt] MI --> MIB[MediaInfoBuilder.kt] MTR --> FL[FrameLoader.kt] MTR --> JNI_MI[JNI: mediainfo.cpp / frame_extractor.cpp / media_thumbnail_retriever.cpp] JNI_MI --> FFmpeg[FFmpeg 原生库] end subgraph media3ext [ExoPlayer FFmpeg 扩展 ExoPlayer FFmpeg Extension] NRF[NextRenderersFactory.kt] --> FAR[FfmpegAudioRenderer.java] NRF --> FVR[FfmpegVideoRenderer.java] NRF --> NTR[NextTextRenderer.kt] FAR --> FAD[FfmpegAudioDecoder.java] FVR --> FVD[FfmpegVideoDecoder.java] FAD & FVD --> JNI_EXT[JNI: ffmain.cpp / ffaudio.cpp / ffvideo.cpp / ffcommon.cpp] JNI_EXT --> FFmpeg end subgraph ffmpeg [FFmpeg 构建系统 FFmpeg Build System] SETUP[setup.sh] --> MBEDTLS[mbedtls 3.4.1] SETUP --> LIBVPX[libvpx 1.13.0] SETUP --> FFMPEG[FFmpeg 6.0] FFMPEG --> OUTPUT[输出 .so 库 Output .so Libraries] end media3ext -.-> OUTPUT mediainfo -.-> OUTPUT

设计哲学 (Design Philosophy)

  1. 模块化分离media3ext 专注实时播放解码,mediainfo 专注元数据分析与缩略图提取,两者共享 FFmpeg 底层但互不依赖。
  2. Feed-and-Drain 解码模型 :采用 Media3 的 SimpleDecoder 框架,实现异步的 avcodec_send_packet / avcodec_receive_frame 解码循环。
  3. JNI 高效内存访问:大量使用 Direct ByteBuffer 实现 Java 与 C++ 之间的零拷贝数据传输。
  4. 硬件加速渲染 :视频通过 ANativeWindow 直接写入屏幕缓冲区,利用系统硬件完成最终的色彩转换。

2. 模块详解 (Module Deep Dive)

A. media3ext 模块 (核心解码与播放)

用途与定位

提供 Media3 官方不直接支持的音视频格式的软件解码支持。该模块是 nextlib 的核心播放组件,通过 FFmpeg 实现对 MKV、FLAC、Vorbis、TrueHD 等格式的软解码。

Java/Kotlin 层核心类

类名 职责 关键方法/特性
FfmpegVideoDecoder 视频软解码器,继承 SimpleDecoder ffmpegInitialize(), ffmpegSendPacket(), ffmpegReceiveFrame(), ffmpegRenderFrame()
FfmpegAudioDecoder 音频软解码器,继承 SimpleDecoder ffmpegInitialize(), ffmpegDecode(), growOutputBuffer() (动态缓冲区)
FfmpegVideoRenderer 视频渲染器,继承 DecoderVideoRenderer 创建 FfmpegVideoDecoder、管理 Surface、格式支持检测
FfmpegAudioRenderer 音频渲染器,继承 DecoderAudioRenderer 创建 FfmpegAudioDecoder、PCM 格式输出决策(16bit vs Float)
FfmpegLibrary 库加载器/守门员 setLibraries(), isAvailable(), getVersion(), supportsFormat(), getCodecName()
NextRenderersFactory 推荐使用的渲染器工厂 继承 DefaultRenderersFactory,在系统解码器基础上追加 FFmpeg 解码器
FFmpegOnlyRenderersFactory 纯 FFmpeg 渲染器工厂 仅使用 FFmpeg 解码器(不使用系统 MediaCodec)
NextTextRenderer 文本/字幕渲染器 包装 TextRenderer 并继承 OffsetRenderer,支持字幕时间轴校正
OffsetRenderer 时间偏移渲染器基类 syncOffsetMilliseconds (固定延迟), syncSpeedMultiplier (速度倍率)

解码器工作流程 (Decoder Workflow)

视频解码器 (FfmpegVideoDecoder)

scss 复制代码
初始化 (Constructor)
    ↓
ffmpegInitialize(codecName, extraData, threads) → 返回 nativeContext
    ↓
decode() 循环:
    ├── ffmpegSendPacket(nativeContext, inputData, timeUs)
    │       └── 调用 avcodec_send_packet() 将压缩数据送入解码器
    └── ffmpegReceiveFrame(nativeContext, outputMode, outputBuffer, decodeOnly)
            └── 调用 avcodec_receive_frame() 获取解码后的 YUV 帧
    ↓
renderToSurface(outputBuffer, surface)
    └── ffmpegRenderFrame() → 通过 ANativeWindow 渲染到屏幕

音频解码器 (FfmpegAudioDecoder)

scss 复制代码
初始化 (Constructor)
    ↓
ffmpegInitialize(codecName, extraData, outputFloat, sampleRate, channelCount)
    ↓
decode() 循环:
    ├── ffmpegDecode(nativeContext, inputData, outputBuffer)
    │       ├── 调用 avcodec_send_packet() + avcodec_receive_frame()
    │       ├── 调用 swr_convert() 进行音频重采样
    │       └── 如缓冲区不足,回调 growOutputBuffer() 扩大缓冲区
    └── 获取 channelCount / sampleRate (首次解码后确定)

ExtraData 处理 (初始化数据提取)

某些编码格式需要额外的初始化数据(ExtraData)才能正确解码。这些数据包含了解码器工作所必需的参数,如分辨率、帧率、编码配置等。

格式 ExtraData 内容 处理方式
H.264 SPS + PPS 连接 initializationData[0] (SPS) 和 initializationData[1] (PPS)
H.265/HEVC VPS + SPS + PPS 使用 initializationData[0]
AAC AudioSpecificConfig 使用 initializationData[0]
Opus OpusHead + OpusTags 使用 initializationData[0]
ALAC ALAC Atom (含 Magic Cookie) initializationData[0] 包装为 ALAC Atom 格式
Vorbis Header0 + Header1 拼接两个头部,添加长度前缀
什么是 SPS、PPS、VPS?

在 H.264 和 H.265 编码中,解码器需要一些**参数集(Parameter Sets)**来初始化解码器上下文。这些参数集包含了视频流的配置信息,必须在解码任何实际视频帧之前提供给解码器。

SPS (Sequence Parameter Set - 序列参数集)

SPS 包含了适用于整个视频序列的全局参数,是解码器工作的"基础配置"。

scss 复制代码
SPS 包含的关键信息:
├── profile_idc          → 编码档次 (Baseline/Main/High)
├── level_idc            → 编码级别 (如 4.0 对应 1080p30)
├── seq_parameter_set_id → SPS 的 ID 标识
├── log2_max_frame_num   → 帧号最大值
├── pic_order_cnt_type   → 画面顺序计数类型
├── max_num_ref_frames   → 最大参考帧数量 (影响 B/P 帧解码)
├── pic_width_in_mbs     → 画面宽度 (宏块单位)
├── pic_height_in_map_units → 画面高度 (宏块单位)
├── frame_mbs_only_flag  → 是否只有帧编码
├── vui_parameters       → 可选的用户信息 (帧率、宽高比等)
└── ...

为什么需要 SPS

  • 解码器需要知道视频的分辨率才能分配正确的缓冲区大小
  • 需要知道编码档次和级别才能确定使用哪些解码工具
  • 需要知道最大参考帧数量才能正确管理参考帧队列

PPS (Picture Parameter Set - 图像参数集)

PPS 包含了适用于单个图像的参数,是对 SPS 的补充。

objectivec 复制代码
PPS 包含的关键信息:
├── pic_parameter_set_id      → PPS 的 ID 标识
├── seq_parameter_set_id      → 引用的 SPS ID
├── entropy_coding_mode_flag  → 熵编码模式 (CAVLC/CABAC)
├── num_slice_groups_minus1   → 切片组数量
├── num_ref_idx_l0_default_active  → L0 列表默认参考帧数
├── num_ref_idx_l1_default_active  → L1 列表默认参考帧数
├── weighted_pred_flag        → 是否启用加权预测
├── pic_init_qp_minus26       → 初始量化参数 (QP)
├── deblocking_filter_control → 去块滤波器控制
└── ...

为什么需要 PPS

  • 熵编码模式决定了如何解析压缩数据
  • 切片信息影响解码的并行处理能力
  • 量化参数影响解码后的图像质量

VPS (Video Parameter Set - 视频参数集)

VPS 是 H.265/HEVC 引入的新概念,用于支持可伸缩视频编码 (SVC)多图层编码

erlang 复制代码
VPS 包含的关键信息:
├── vps_parameter_set_id      → VPS 的 ID 标识
├── vps_max_layers_minus1     → 最大图层数量
├── vps_max_sub_layers_minus1 → 最大子图层数量
├── vps_num_layer_sets_minus1 → 图层集合数量
├── 图层依赖关系               → 哪些图层依赖哪些参考层
├── 各图层的分辨率/帧率信息    → 不同质量层的参数
└── ...

为什么需要 VPS

  • H.265 支持多层编码(如基础层 720p + 增强层 1080p)
  • VPS 描述了各层之间的依赖关系
  • 解码器根据 VPS 决定解码哪些层

三者的关系

makefile 复制代码
H.264:
┌─────────────────────────────────────┐
│  SPS (序列参数集)                     │
│  - 全局配置 (分辨率、编码档次)          │
│  - 一个视频序列通常只有一个 SPS         │
└──────────────┬──────────────────────┘
               │ 引用
┌──────────────▼──────────────────────┐
│  PPS (图像参数集)                     │
│  - 图像级配置 (熵编码、切片)            │
│  - 一个序列可能有多个 PPS              │
└──────────────┬──────────────────────┘
               │ 引用
┌──────────────▼──────────────────────┐
│  实际视频帧 (I/P/B 帧)                │
│  - 引用 PPS 获取解码参数              │
│  - PPS 间接引用 SPS                   │
└─────────────────────────────────────┘

H.265/HEVC:
┌─────────────────────────────────────┐
│  VPS (视频参数集)                     │
│  - 多层编码配置                        │
└──────────────┬──────────────────────┘
               │ 引用
┌──────────────▼──────────────────────┐
│  SPS (序列参数集)                     │
│  - 单层的全局配置                      │
└──────────────┬──────────────────────┘
               │ 引用
┌──────────────▼──────────────────────┐
│  PPS (图像参数集)                     │
│  - 图像级配置                          │
└──────────────┬──────────────────────┘
               │ 引用
┌──────────────▼──────────────────────┐
│  实际视频帧                           │
└─────────────────────────────────────┘

在 nextlib 中的处理

java 复制代码
// FfmpegVideoDecoder.java
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
    if (initializationData.isEmpty()) return null;
    switch (mimeType) {
        case MimeTypes.VIDEO_H264 -> {
            // 对于 H.264,连接 SPS 和 PPS
            byte[] sps = initializationData.get(0);
            byte[] pps = initializationData.get(1);
            byte[] extraData = new byte[sps.length + pps.length];
            System.arraycopy(sps, 0, extraData, 0, sps.length);
            System.arraycopy(pps, 0, extraData, sps.length, pps.length);
            return extraData;
        }
        case MimeTypes.VIDEO_H265 -> {
            // 对于 H.265,使用完整的 initializationData[0] (已包含 VPS+SPS+PPS)
            return initializationData.get(0);
        }
        default -> null;
    }
}

在 C++ 中的使用

cpp 复制代码
// ffvideo.cpp - createVideoContext()
if (extraData) {
    jsize size = env->GetArrayLength(extraData);
    codecContext->extradata_size = size;
    // 分配额外空间 (FFmpeg 要求 extraData 后有 AV_INPUT_BUFFER_PADDING_SIZE 的填充)
    codecContext->extradata = (uint8_t *) av_malloc(size + AV_INPUT_BUFFER_PADDING_SIZE);
    env->GetByteArrayRegion(extraData, 0, size, (jbyte *) codecContext->extradata);
}
什么是 ALAC Atom?

ALAC (Apple Lossless Audio Codec) 是苹果开发的无损音频编码格式。与 FLAC 类似,它可以完全还原原始音频数据,压缩率约为 50-60%。

Magic Cookie (魔法曲奇)

ALAC 编码需要一个特殊的配置数据块,称为 "Magic Cookie"。它包含了:

arduino 复制代码
Magic Cookie 内容:
├── 音频格式版本
├── 最大每帧样本数
├── 兼容版本
├── 位深度 (16/20/24 bit)
├── 平均比特率
├── 采样率
├── 声道数
├── 声道布局
└── 其他解码器特定参数

ALAC Atom 结构

在 MP4/M4A 容器中,Magic Cookie 被包装在一个称为 "alac" 的 Atom(MP4 的数据块)中:

scss 复制代码
ALAC Atom 结构:
┌─────────────────────────────────────┐
│  atom_size (4 字节)                  │
│  → 整个 Atom 的大小 (12 + cookie)     │
├─────────────────────────────────────┤
│  atom_type (4 字节)                  │
│  → 0x616c6163 = "alac" (ASCII)       │
├─────────────────────────────────────┤
│  version (1 字节) + flags (3 字节)   │
│  → 通常为 0                           │
├─────────────────────────────────────┤
│  Magic Cookie 数据                   │
│  → 实际的解码器配置参数                │
└─────────────────────────────────────┘

为什么需要包装为 ALAC Atom

FFmpeg 的 ALAC 解码器期望 ExtraData 是完整的 ALAC Atom 格式,而不是原始的 Magic Cookie。这是因为:

  1. 历史原因:早期 FFmpeg 实现直接解析 MP4 容器中的 alac Atom
  2. 版本兼容:Atom 头部包含版本信息,解码器可以据此调整行为
  3. 完整性检查:Atom 大小字段可用于验证数据完整性

在 nextlib 中的处理

java 复制代码
// FfmpegAudioDecoder.java
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
    // initializationData[0] 只包含 Magic Cookie
    // 需要将其包装为完整的 ALAC Atom
    byte[] magicCookie = initializationData.get(0);
    int alacAtomLength = 12 + magicCookie.length;  // 4(size) + 4(type) + 4(ver/flags) + cookie
    ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
    
    // 写入 Atom 大小 (大端序)
    alacAtom.putInt(alacAtomLength);
    
    // 写入 Atom 类型 "alac" (0x616c6163)
    alacAtom.putInt(0x616c6163);
    
    // 写入版本和标志 (全 0)
    alacAtom.putInt(0);
    
    // 写入 Magic Cookie 数据
    alacAtom.put(magicCookie, 0, magicCookie.length);
    
    return alacAtom.array();
}

图示对比

css 复制代码
Media3 提供的 initializationData[0]:
┌──────────────────────────┐
│  Magic Cookie (可变长度)   │
│  → 仅包含解码器参数         │
└──────────────────────────┘

FFmpeg 期望的 ALAC Atom:
┌────────┬────────┬────────┬──────────────────┐
│ 大小   │ 类型   │ 版本   │  Magic Cookie    │
│ 4 字节 │ 4 字节 │ 4 字节 │   (可变长度)       │
│        │ "alac" │        │                  │
└────────┴────────┴────────┴──────────────────┘
其他格式的 ExtraData 说明

AAC - AudioSpecificConfig

AAC 的 ExtraData 称为 AudioSpecificConfig (ASC),包含:

bash 复制代码
ASC 内容:
├── audio_object_type     → 编码档次 (AAC-LC/HE-AAC/etc)
├── sampling_frequency_index → 采样率索引
├── channel_configuration → 声道配置
├── frame_length_flag     → 帧长度标志
├── depends_on_core_coder → 是否依赖核心编码器
└── ...

Opus - OpusHead + OpusTags

Opus 的 ExtraData 包含两个部分:

scss 复制代码
OpusHead (必须):
├── 魔数 "OpusHead"
├── 版本号
├── 声道数
├── 预跳过样本数
├── 输入采样率
└── 输出增益

OpusTags (可选):
├── 供应商字符串
├── 用户评论
└── 元数据

Vorbis - 头部拼接

Vorbis 需要三个头部中的前两个:

css 复制代码
Header 0 (Identification Header):
├── 魔数
├── 版本号
├── 声道数
├── 采样率
├── 最大比特率
├── 标称比特率
└── 最小比特率

Header 1 (Comment Header):
├── 供应商
├── 用户评论
└── 元数据

Header 2 (Setup Header) → 通常不在此处传递

MIME 到 FFmpeg 解码器映射表

FfmpegLibrary.getCodecName() 实现了 MIME 类型到 FFmpeg 解码器名称的映射:

音频映射

  • AUDIO_AACaac
  • AUDIO_MPEGmp3
  • AUDIO_AC3ac3
  • AUDIO_E_AC3eac3
  • AUDIO_TRUEHDtruehd
  • AUDIO_DTSdca
  • AUDIO_VORBISvorbis
  • AUDIO_OPUSopus
  • AUDIO_FLACflac
  • AUDIO_ALACalac
  • AUDIO_MLAWpcm_mulaw
  • AUDIO_ALAWpcm_alaw

视频映射

  • VIDEO_H264h264
  • VIDEO_H265hevc
  • VIDEO_MPEGmpegvideo
  • VIDEO_MPEG2mpeg2video
  • VIDEO_VP8libvpx
  • VIDEO_VP9libvpx-vp9

NextRenderersFactory 扩展渲染器插入策略

NextRenderersFactory 通过重写 buildAudioRenderers()buildVideoRenderers()buildTextRenderers() 方法,将 FFmpeg 解码器插入到渲染器列表中:

kotlin 复制代码
// 扩展渲染器插入位置策略
when (extensionRendererMode) {
    EXTENSION_RENDERER_MODE_OFF → 不插入 FFmpeg 解码器
    EXTENSION_RENDERER_MODE_PREFER → 插入到系统解码器之前 (index = size - 1)
    EXTENSION_RENDERER_MODE_ON → 插入到系统解码器之后 (index = size)
}

推荐配置 :使用 EXTENSION_RENDERER_MODE_PREFER 可以让 FFmpeg 解码器优先于系统解码器工作,从而支持更多格式。

原生层 (C++) 架构

文件 职责 关键函数
ffmain.cpp JNI 入口,库版本查询 JNI_OnLoad(), ffmpegGetVersion(), ffmpegHasDecoder()
ffcommon.h/cpp 公共工具函数 releaseContext(), getCodecByName(), logError()
ffvideo.cpp 视频解码 JNI 实现 createVideoContext(), ffmpegSendPacket(), ffmpegReceiveFrame(), ffmpegRenderFrame()
ffaudio.cpp 音频解码 JNI 实现 createContext(), decodePacket(), ffmpegDecode()

关键数据结构

JniContext (视频)

cpp 复制代码
struct JniContext {
    AVCodecContext *codecContext;    // FFmpeg 解码上下文
    SwsContext *swsContext;          // 色彩转换上下文 (YUV → YV12)
    ANativeWindow *native_window;    // Android 原生窗口
    jobject surface;                 // Java Surface 引用
    // ... 缓存的 JNI 字段和方法 ID
};

B. mediainfo 模块 (媒体分析与帧提取)

用途与定位

不依赖播放器上下文,快速获取媒体文件的元数据(格式、时长、流信息)和视频帧缩略图。启动速度极快,适合用于文件浏览器、媒体库预览等场景。

Kotlin 层核心类

类名 职责 关键方法/特性
MediaInfo 媒体信息数据类 包含格式、时长、视频流、音频流、字幕流、章节列表
MediaInfoBuilder 媒体信息构建器 from(filePath/descriptor/uri), build(), JNI 回调方法
MediaThumbnailRetriever 缩略图检索器 getEmbeddedPicture(), getFrameAtTime(), getFrameAtIndex()
FrameLoader 帧加载器 nativeLoadFrame(), nativeGetFrame(), nativeRelease()
VideoStream 视频流数据类 索引、标题、编解码器、语言、码率、帧率、宽高、旋转角度
AudioStream 音频流数据类 索引、标题、编解码器、语言、采样格式、采样率、声道数
SubtitleStream 字幕流数据类 索引、标题、编解码器、语言
Chapter 章节数据类 索引、起始时间、结束时间、标题
PathUtil URI 转路径工具 处理 StorageProvider、DownloadsProvider、MediaProvider 等

JNI 回调机制

MediaInfoBuilder 通过 JNI 回调逐步构建 MediaInfo 对象:

kotlin 复制代码
// JNI 回调方法 (由 C++ 层调用)
@Keep
private fun onMediaInfoFound(fileFormatName: String, duration: Long)
private fun onVideoStreamFound(index, title, codecName, language, disposition, 
                                bitRate, frameRate, frameWidth, frameHeight, rotation, 
                                frameLoaderContext)
private fun onAudioStreamFound(index, title, codecName, language, disposition,
                                bitRate, sampleFormat, sampleRate, channels, channelLayout)
private fun onSubtitleStreamFound(index, title, codecName, language, disposition)
private fun onChapterFound(index, title, start, end)
private fun onError()

数据源支持

MediaInfoBuilder.from() 支持三种数据源:

  1. 文件路径from(filePath: String) → 直接调用 avformat_open_input()
  2. FileDescriptorfrom(descriptor: ParcelFileDescriptor) → 使用 pipe: 协议
  3. URIfrom(context, uri) → 智能解析,优先尝试转本地路径,失败则使用 FileDescriptor

缩略图提取流程

scss 复制代码
MediaThumbnailRetriever.getFrameAtTime(timeMs)
    ↓
FrameLoader.nativeGetFrame(frameLoaderContextHandle, timeMs)
    ↓
frame_extractor_get_frame() (C++)
    ├── av_seek_frame() → Seek 到目标时间
    ├── read_frame() → 循环读取 AVPacket 并解码
    ├── sws_getContext() → 创建缩放上下文 (任意格式 → RGBA)
    ├── AndroidBitmap_lockPixels() → 锁定 Bitmap 像素
    ├── sws_scale() → 缩放并转换为 RGBA
    └── AndroidBitmap_unlockPixels() → 解锁并返回 Bitmap

原生层 (C++) 架构

文件 职责 关键函数
main.cpp JNI 入口 JNI_OnLoad(), JNI_OnUnload()
mediainfo.cpp 媒体信息解析核心 media_info_build(), onVideoStreamFound(), onAudioStreamFound()
frame_extractor.cpp 帧提取器核心 read_frame(), frame_extractor_load_frame(), frame_extractor_get_frame()
media_thumbnail_retriever.cpp 缩略图检索器 JNI getEmbeddedPicture(), getFrameAtTime(), getFrameAtIndex()
frame_loader_context.h/cpp 帧加载上下文 FrameLoaderContext 结构体,handle 与指针互转
utils.h/cpp JNI 工具 缓存 Java 方法 ID,变参方法调用辅助
log.h 日志宏 LOGV/LOGD/LOGI/LOGW/LOGE

视频旋转角度处理

mediainfo.cpp 中通过两种方式获取视频旋转角度:

  1. Metadata Rotate Tag :读取流元数据中的 "rotate" 字段
  2. Display Matrix :通过 av_stream_get_side_data() 获取 AV_PKT_DATA_DISPLAYMATRIX,调用 av_display_rotation_get() 计算旋转角度
cpp 复制代码
// 优先级:Display Matrix > Metadata Rotate
int rotation = 0;
AVDictionaryEntry *rotateTag = av_dict_get(stream->metadata, "rotate", nullptr, 0);
if (rotateTag && *rotateTag->value) {
    rotation = atoi(rotateTag->value);
}
uint8_t *displaymatrix = av_stream_get_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, nullptr);
if (displaymatrix) {
    double theta = av_display_rotation_get((int32_t *) displaymatrix);
    rotation = (int) (-theta) % 360;  // 取反并归一化
}

3. FFmpeg 核心库深度解析 (Native Libraries Deep Dive)

💡 命名规范 (Prefix Convention):

  • av : 代表 Audio/Video 。例如 AVFrame(原始帧)、AVPacket(压缩包)。
  • sw : 代表 Software (软件算法实现)。这暗示了该操作在 CPU 上完成,而非 GPU 硬件加速。
    • sws = Software Scaling (libswscale)
    • swr = Software Resampling (libswresample)

各子库详尽职责:

1. libavformat (封装/解封装库)

  • 核心职责 : 处理媒体文件的容器层(Container Format)。它不关心音视频内容本身,只负责"打开"文件(如 MP4, MKV)并从中分离出独立的音频流、视频流和字幕流。这个过程称为解封装 (Demuxing)
  • 工作流程 : 当接收到一个媒体文件时,libavformat 会首先探测其格式,然后读取文件头信息,定位到各个数据流。之后,它可以按顺序读取每个流的数据块,并将其打包成 AVPacket 结构体。AVPacket 包含压缩后的原始数据和时间戳等元信息。
  • 核心 API :
    • avformat_open_input():打开媒体文件,创建 AVFormatContext
    • avformat_find_stream_info():获取流信息(编解码器参数、时长等)
    • av_read_frame():读取一个 AVPacket
    • av_seek_frame():Seek 到指定时间戳
    • avformat_close_input():关闭文件,释放资源
  • 项目应用 : 在 mediainfo 模块中解析文件元数据;在播放流程中提取音频和视频的 AVPacket

2. libavcodec (编解码库)

  • 核心职责 : FFmpeg 最核心、最庞大的部分,负责将 AVPacket(压缩数据)解码成原始的视频帧(AVFrame)或音频采样。这个过程称为解码 (Decoding)
  • 工作流程 : 接收 AVPacket 后,libavcodec 会查找对应的解码器(如 H.264, AAC),然后通过 avcodec_send_packetavcodec_receive_frame 的异步机制,将压缩数据转化为原始数据。
  • 核心 API :
    • avcodec_find_decoder():根据 codec ID 查找解码器
    • avcodec_alloc_context3():创建解码器上下文
    • avcodec_open2():打开解码器
    • avcodec_send_packet():发送压缩数据到解码器(异步)
    • avcodec_receive_frame():从解码器获取原始数据(异步)
    • avcodec_flush_buffers():清空内部缓冲区(用于 Seek 后重置)
    • avcodec_free_context():释放解码器上下文
  • 项目应用 : 在 ffvideo.cppffaudio.cpp 中通过 avcodec_receive_frame 解码出原始数据。

3. libswscale (视频图像转换库)

  • 核心职责 : 专门处理视频图像的缩放 (Scaling)裁剪 (Cropping)像素格式转换 (Pixel Format Conversion)
  • 原理 : 视频解码出的格式(如 YUV420P)往往不能直接显示。sws_scale 负责将其转换为系统能理解的特定像素格式(如 Android ANativeWindow 要求的 YV12),并处理分辨率适配。
  • 核心 API :
    • sws_getContext():创建缩放上下文
    • sws_scale():执行缩放和格式转换
    • sws_freeContext():释放缩放上下文
  • 项目应用 : 在 ffvideo.cpp 中将各种解码后的 YUV 格式转换为 Android 兼容的 YV12;在 frame_extractor.cpp 中将任意像素格式转换为 RGBA 用于 Bitmap。

4. libswresample (音频重采样库)

  • 核心职责 : 专门处理音频数据的重采样 (Resampling)声道布局转换样本格式转换
  • 原理 : Android AudioTrack 只能接受特定格式,swr_convert 确保解码出的各种音频流都能被系统正确播放。
  • 核心 API :
    • swr_alloc():分配重采样上下文
    • av_opt_set_chlayout():设置输入/输出声道布局
    • av_opt_set_int():设置采样率、样本格式等参数
    • swr_init():初始化重采样上下文
    • swr_convert():执行音频格式转换
    • swr_free():释放重采样上下文
  • 项目应用 : 在 ffaudio.cpp 中将音频统一转换为 Android 播放器要求的 PCM 格式(16bit 或 Float)。

5. libavutil (通用工具库)

  • 核心职责: 这是一个基础库,为 FFmpeg 其他所有库提供底层的公共函数(内存分配、日志记录、数学运算等)。
  • 核心 API :
    • av_malloc() / av_free():内存分配(对齐优化)
    • av_packet_alloc() / av_packet_free():AVPacket 分配
    • av_frame_alloc() / av_frame_free():AVFrame 分配
    • av_err2str():错误码转字符串
    • av_rescale_q():时间戳换算(不同 time_base 之间)
    • av_get_sample_fmt_name():获取音频格式名称
    • av_channel_layout_describe():描述声道布局
  • 项目应用: 作为所有 C++ 文件的基础依赖。

核心数据结构详解

AVPacket (压缩数据包)

cpp 复制代码
typedef struct AVPacket {
    int64_t pts;          // 显示时间戳 (Presentation Time Stamp)
    int64_t dts;          // 解码时间戳 (Decode Time Stamp)
    uint8_t *data;        // 压缩数据指针
    int size;             // 数据大小
    int stream_index;     // 流索引 (用于区分音频/视频/字幕流)
    int flags;            // 标志位 (AV_PKT_FLAG_KEY 表示关键帧)
    // ...
} AVPacket;

AVFrame (原始数据帧)

cpp 复制代码
typedef struct AVFrame {
    uint8_t *data[4];     // 原始数据指针 (视频: Y/U/V 平面, 音频: 声道数据)
    int linesize[4];      // 每行字节数 (视频: 步长/Stride)
    int width, height;    // 视频宽高
    int64_t pts;          // 时间戳
    AVPixelFormat format; // 像素格式 (如 AV_PIX_FMT_YUV420P)
    int nb_samples;       // 音频样本数 (仅音频)
    // ...
} AVFrame;

AVCodecContext (解码器上下文)

cpp 复制代码
typedef struct AVCodecContext {
    const AVCodec *codec;         // 编解码器
    int width, height;            // 视频宽高
    AVPixelFormat pix_fmt;        // 像素格式
    int sample_rate;              // 音频采样率
    AVChannelLayout ch_layout;    // 声道布局
    uint8_t *extradata;           // 额外数据 (SPS/PPS 等)
    int extradata_size;           // 额外数据大小
    void *opaque;                 // 用户自定义指针 (项目中用于缓存 SwrContext)
    // ...
} AVCodecContext;

4. 构建系统与依赖管理

FFmpeg 构建流程

ffmpeg/setup.sh 是自动化构建脚本,负责下载并编译所有依赖:

css 复制代码
setup.sh 执行流程:
    ↓
1. 下载源码
    ├── mbedtls 3.4.1 (HTTPS/TLS 支持)
    ├── libvpx 1.13.0 (VP8/VP9 支持)
    └── FFmpeg 6.0
    ↓
2. 编译依赖库 (按 ABI 分别编译)
    ├── buildMbedTLS() → CMake 构建
    └── buildLibVpx() → configure + make 构建
    ↓
3. 编译 FFmpeg
    └── buildFfmpeg() → configure + make 构建
        ├── 启用解码器: vorbis, opus, flac, alac, truehd, h264, hevc, libvpx_vp8, libvpx_vp9 等
        ├── 启用协议: file, http, https, rtmp, rtp, tls 等
        ├── 启用库: libswresample, libswscale, libavformat, libavcodec, libavutil
        └── 链接 mbedtls 和 libvpx
    ↓
4. 输出产物
    ├── ffmpeg/output/include/<abi>/ → 头文件
    └── ffmpeg/output/lib/<abi>/ → .so 动态库

支持的 ABI 与架构

ABI 架构 工具链 特殊配置
armeabi-v7a ARM 32位 armv7a-linux-androideabi21-clang 禁用 NEON
arm64-v8a ARM 64位 aarch64-linux-android21-clang 默认启用 NEON
x86 x86 32位 i686-linux-android21-clang 禁用汇编优化
x86_64 x86 64位 x86_64-linux-android21-clang 禁用 SSE/AVX

支持的解码器列表

音频解码器

  • 无损格式:flac, alac, truehd, mlp
  • 有损格式:vorbis, opus, aac, mp3, ac3, eac3, dca, amrnb, amrwb
  • PCM 格式:pcm_mulaw, pcm_alaw

视频解码器

  • 主流格式:h264, hevc (H.265), mpeg2video, mpegvideo
  • WebM 格式:libvpx_vp8, libvpx_vp9

Gradle 集成

media3ext/build.gradle.kts 中注册了 ffmpegSetup 任务,在 preBuild 前自动触发 FFmpeg 编译:

kotlin 复制代码
val ffmpegSetup by tasks.registering(Exec::class) {
    workingDir = file("../../ffmpeg")
    commandLine("bash", "setup.sh")
}
tasks.named("preBuild").configure {
    dependsOn(ffmpegSetup)
}

注意mediainfo 模块的 ffmpegSetup 任务默认注释,因为它依赖 media3ext 模块先构建 FFmpeg。

依赖版本 (gradle/libs.versions.toml)

依赖 版本
Android Gradle Plugin 9.1.0
Kotlin 2.3.20
Media3 1.10.0
CMake 3.22.1
NDK 25.2.9519653

5. 关键技术笔记与原理说明 (Key Technical Notes)

A. 为什么选择 YUV 渲染而不是 RGB?

  • 效率优势 :由于人眼对亮度(Luma, Y)比色彩(Chroma, U/V)更敏感,YUV 4:2:0 采样比 RGB888 节省约 50% 的带宽和内存占用。具体来说:

    • RGB888:每像素 3 字节 (24 bit)
    • YUV420P:每像素 1.5 字节 (Y 全采样,U/V 各四分之一)
  • 硬件加速 (Hardware Path) :Android 显示系统(SurfaceFlinger)能直接处理 YUV 数据。在 ffvideo.cpp 中,我们将解码后的数据转为 YV12,利用 ANativeWindow 直接交给系统,利用 GPU 或专用显示硬件完成最终的 RGB 转换,更加省电且高效。

  • YV12 格式布局

    scss 复制代码
    Y 平面:stride × height 字节
    V 平面:(stride/2) × (height/2) 字节 (对齐到 16 字节边界)
    U 平面:(stride/2) × (height/2) 字节 (对齐到 16 字节边界)

B. Dolby TrueHD 的特殊处理逻辑

  • 特性:无损压缩编码,通常作为 Dolby Atmos 的载体。
  • 问题 :由于其复杂的内部状态,FFmpeg 的标准 avcodec_flush_buffers 在 Seek(进度跳转)后有时无法完全重置 TrueHD 解码器状态。这会导致 Seek 后播放出现爆音、静音或解码错误。
  • 方案 :在 ffaudio.cpp 中,针对 TrueHD 采取了销毁并根据 ExtraData 重新创建解码上下文的策略,强制重置解码器的内部状态机,以确保跳转后的播放稳定性。
cpp 复制代码
// ffaudio.cpp 中的 TrueHD 特殊处理
if (context->codec_id == AV_CODEC_ID_TRUEHD) {
    AVCodecID codecId = context->codec_id;
    AVSampleFormat reqFmt = context->request_sample_fmt;
    releaseContext(context);  // 销毁旧上下文
    auto *codec = const_cast<AVCodec *>(avcodec_find_decoder(codecId));
    return (jlong) createContext(env, codec, extra_data, (reqFmt == OUTPUT_FORMAT_PCM_FLOAT), -1, -1);
}
avcodec_flush_buffers(context);  // 其他格式使用标准 flush

C. 如何新增一种解码格式 (以 VP10 为例)

  1. FFmpeg 重新编译 :在 ffmpeg/setup.shENABLED_DECODERS 变量中添加 vp10,确保底层库编译脚本开启了 --enable-decoder=vp10
  2. MIME 映射 :在 FfmpegLibrary.javagetCodecName() 中建立映射关系,例如 MimeTypes.VIDEO_VP10 -> "libvpx-vp10"
  3. 配置数据提取 :在 FfmpegVideoDecoder.javagetExtraData() 中处理该格式所需的初始化数据(如果有)。
  4. 重新构建 :运行 ./gradlew :media3ext:assemble 触发 FFmpeg 重新编译。

D. Feed-and-Drain 解码模型详解

这是 Media3 SimpleDecoder 框架的核心模式,也是理解本项目解码流程的关键:

scss 复制代码
输入队列 (Input Buffers)          解码器 (Decoder)          输出队列 (Output Buffers)
┌─────────────────┐              ┌──────────────┐          ┌──────────────────┐
│ Buffer 0 (编码) │──SendPacket──▶│              │          │                  │
│ Buffer 1 (编码) │──SendPacket──▶│  FFmpeg      │──Receive─▶│ Buffer 0 (解码帧)│
│ Buffer 2 (编码) │──SendPacket──▶│  Decoder     │──Receive─▶│ Buffer 1 (解码帧)│
│       ...       │              │              │          │       ...        │
└─────────────────┘              └──────────────┘          └──────────────────┘

关键特性

  1. 异步解码avcodec_send_packet()avcodec_receive_frame() 是异步的。发送一个 Packet 后,不一定立即能收到 Frame(B 帧需要参考后续帧才能解码)。
  2. 队列管理:Java 层维护固定数量的输入/输出缓冲区,通过循环实现持续解码。
  3. EAGAIN 处理 :当 avcodec_send_packet() 返回 AVERROR(EAGAIN) 时,表示解码器内部队列已满,需要先调用 avcodec_receive_frame() 取出已解码的帧。

E. 时间戳处理 (PTS/DTS)

  • PTS (Presentation Time Stamp):帧应该被显示的时间戳。
  • DTS (Decode Time Stamp):帧应该被解码的时间戳。
  • 区别:对于 B 帧(双向预测帧),解码顺序和显示顺序不同,因此 DTS ≠ PTS。
  • 项目中的处理 :在 ffvideo.cpp 中,packet.pts = input_time 将 Java 层传入的微秒级时间戳直接赋值给 PTS。FFmpeg 内部会处理 DTS 的换算。

F. 音频重采样 (Resampling) 原理

为什么需要音频重采样?

音频重采样是音频解码流程中必不可少的环节,主要原因包括:

1. 解码器输出格式不固定

不同的音频编码格式解码后会产生不同的样本格式:

编码格式 解码后常见格式 说明
AAC AV_SAMPLE_FMT_FLTP 32位浮点,平面存储
MP3 AV_SAMPLE_FMT_S16P 16位整数,平面存储
FLAC AV_SAMPLE_FMT_S32P 32位整数,平面存储
Opus AV_SAMPLE_FMT_FLT 32位浮点,交错存储
Vorbis AV_SAMPLE_FMT_FLTP 32位浮点,平面存储

问题 :Android AudioTrack 只接受特定格式:

  • ENCODING_PCM_16BIT(16位整数)
  • ENCODING_PCM_FLOAT(32位浮点)

解决 :必须通过 swr_convert() 将解码后的各种格式统一转换为 Android 能接受的格式。

2. 采样率不匹配

不同音频流的采样率各不相同:

yaml 复制代码
常见采样率:
├── 8000 Hz    → 电话语音
├── 22050 Hz   → 低质量网络音频
├── 44100 Hz   → CD 标准
├── 48000 Hz   → 视频音频标准
└── 96000 Hz   → 高清音频

问题

  • 视频中的音频可能是 48000 Hz
  • 但音频硬件/声卡可能只支持 44100 Hz 输出
  • 或者应用需要统一所有音频到固定采样率

解决:重采样将源采样率转换为目标采样率。

3. 声道布局转换

音频流的声道数量差异很大:

scss 复制代码
声道布局示例:
├── Mono (1.0)        → 单声道
├── Stereo (2.0)      → 立体声(左+右)
├── 5.1 Surround      → 6声道环绕声
└── 7.1 Surround      → 8声道环绕声

问题 :用户用普通耳机听 5.1 声道音频时,需要将 6 声道下混(Downmix)为 2 声道立体声。

解决swr_convert() 可以自动处理声道布局转换。

4. 平面格式 vs 交错格式

FFmpeg 支持两种数据存储方式:

scss 复制代码
平面格式 (Planar):          交错格式 (Interleaved):
┌─────┬─────┬─────┐         ┌─────┬─────┬─────┬─────┐
│ LLL │ RRR │ ... │         │ L R │ L R │ L R │ ... │
└─────┴─────┴─────┘         └─────┴─────┴─────┴─────┘
每个声道独立存储               声道数据交替存储

问题 :FFmpeg 解码器通常输出平面格式 (如 FLTP),而 Android AudioTrack 需要交错格式 (如 S16)。

解决:重采样同时完成格式转换和存储方式转换。

重采样涉及的三个维度

音频重采样涉及三个维度的转换:

  1. 采样格式转换 :如 AV_SAMPLE_FMT_FLTP (32-bit Float Planar) → AV_SAMPLE_FMT_S16 (16-bit Signed Integer)
  2. 采样率转换:如 48000 Hz → 44100 Hz
  3. 声道布局转换:如 5.1 声道 → 立体声 (Stereo)

nextlib 中的实际应用

ffaudio.cpp 中,通过 context->request_sample_fmt 指定输出格式:

cpp 复制代码
// 根据 Java 层要求决定输出格式
context->request_sample_fmt = outputFloat 
    ? AV_SAMPLE_FMT_FLT   // 32位浮点 (ENCODING_PCM_FLOAT)
    : AV_SAMPLE_FMT_S16;  // 16位整数 (ENCODING_PCM_16BIT)

重采样上下文被这样初始化:

cpp 复制代码
SwrContext *resampleContext;
if (context->opaque) {
    resampleContext = (SwrContext *) context->opaque;  // 复用已有上下文
} else {
    resampleContext = swr_alloc();  // 首次创建
    
    // 设置输入/输出声道布局
    av_opt_set_chlayout(resampleContext, "in_chlayout", &context->ch_layout, 0);
    av_opt_set_chlayout(resampleContext, "out_chlayout", &context->ch_layout, 0);
    
    // 设置输入/输出采样率
    av_opt_set_int(resampleContext, "in_sample_rate", context->sample_rate, 0);
    av_opt_set_int(resampleContext, "out_sample_rate", context->sample_rate, 0);
    
    // 设置输入/输出样本格式
    av_opt_set_int(resampleContext, "in_sample_fmt", context->sample_fmt, 0);
    av_opt_set_int(resampleContext, "out_sample_fmt", context->request_sample_fmt, 0);
    
    swr_init(resampleContext);
    context->opaque = resampleContext;  // 缓存到 opaque 指针
}

关键优化SwrContext 被缓存在 context->opaque 指针中,避免每次解码都重新初始化,显著提升性能。

不重采样的后果

如果不进行重采样,会出现以下问题:

问题 现象
格式不匹配 播放出来是噪音/爆音(数据被错误解析)
采样率不匹配 播放速度变快/变慢,音调异常
声道不匹配 只有部分声道有声音,或者声音混乱
存储方式不匹配 左右声道交错错误,声音失真

重采样流程总结

scss 复制代码
解码器输出 ──→ [重采样 swr_convert()] ──→ 播放器可接受的格式
   (任意格式)                              (固定格式)
       ↓                                      ↓
   FLTP/S32P/...                          S16/FLT
   48000Hz                                44100Hz
   5.1声道                                立体声
   平面存储                                交错存储

重采样是音频解码流程中的"翻译官",确保不同来源、不同格式的音频数据能够被统一的播放硬件正确播放。


6. 核心流程示例 (Workflow Examples)

视频帧渲染流程:

scss 复制代码
1. FfmpegVideoDecoder.decode() (Java)
       ↓ 从输入队列获取编码数据
2. ffmpegSendPacket(nativeContext, inputData, timeUs) (JNI)
       ↓ 调用 avcodec_send_packet()
3. ffmpegReceiveFrame(nativeContext, outputMode, outputBuffer) (JNI)
       ↓ 调用 avcodec_receive_frame() 获取 AVFrame (YUV)
       ↓ 将 YUV 数据拷贝到 Java VideoDecoderOutputBuffer 的 Direct ByteBuffer
4. FfmpegVideoDecoder.renderToSurface(outputBuffer, surface) (Java)
       ↓
5. ffmpegRenderFrame(nativeContext, surface, outputBuffer) (JNI)
       ├── ANativeWindow_fromSurface() → 获取 Native Window
       ├── ANativeWindow_setBuffersGeometry() → 设置 YV12 格式
       ├── sws_getContext() → 创建色彩转换上下文
       ├── ANativeWindow_lock() → 锁定屏幕缓冲区
       ├── sws_scale() → 将源 YUV 转换为 YV12 并拷贝到屏幕缓冲区
       └── ANativeWindow_unlockAndPost() → 显示画面

音频解码流程:

scss 复制代码
1. FfmpegAudioDecoder.decode() (Java)
       ↓ 从输入队列获取编码数据
2. ffmpegDecode(nativeContext, inputData, outputBuffer) (JNI)
       ├── avcodec_send_packet() → 发送编码数据
       └── avcodec_receive_frame() → 获取解码后的音频帧
3. 音频重采样 (在 decodePacket() 内部)
       ├── 检查 context->opaque 是否有缓存的 SwrContext
       ├── 如无,创建并初始化 SwrContext
       └── swr_convert() → 转换为 PCM 16bit 或 Float
4. 如输出缓冲区不足
       ↓ 回调 Java 层 growOutputBuffer()
       ↓ 重新分配 Direct ByteBuffer
5. 返回解码后的字节数

媒体信息解析流程:

scss 复制代码
1. MediaInfoBuilder.from(filePath) (Kotlin)
       ↓
2. nativeCreateFromPath(filePath) (JNI)
       ↓
3. media_info_build() (C++)
       ├── avformat_open_input() → 打开文件
       ├── avformat_find_stream_info() → 获取流信息
       ├── onMediaInfoFound() → 回调格式和时长
       └── 遍历所有流:
            ├── AVMEDIA_TYPE_VIDEO → onVideoStreamFound()
            ├── AVMEDIA_TYPE_AUDIO → onAudioStreamFound()
            └── AVMEDIA_TYPE_SUBTITLE → onSubtitleStreamFound()
       └── 遍历所有章节:
            └── onChapterFound()

缩略图提取流程:

scss 复制代码
1. MediaThumbnailRetriever.getFrameAtTime(timeMs)
       ↓
2. FrameLoader.nativeGetFrame(frameLoaderContextHandle, timeMs)
       ↓
3. frame_extractor_get_frame() (C++)
       ├── 计算 Seek 位置 (考虑 time_base 换算)
       ├── av_seek_frame() → Seek 到目标位置
       ├── read_frame() 循环:
       │     ├── av_read_frame() → 读取 AVPacket
       │     ├── avcodec_send_packet() → 发送数据包
       │     └── avcodec_receive_frame() → 获取解码帧
       ├── 如 Seek 后第一帧解码失败,回退到文件开头重新解码
       ├── sws_getContext() → 创建缩放上下文 (源格式 → RGBA)
       ├── AndroidBitmap_lockPixels() → 锁定 Bitmap 像素缓冲区
       ├── av_image_fill_arrays() → 将 Bitmap 缓冲区包装为 AVFrame
       ├── sws_scale() → 执行缩放和格式转换
       └── AndroidBitmap_unlockPixels() → 解锁并返回 Bitmap

7. 开发避坑指南 (Developer's Survival Guide)

A. 内存管理:JNI 的"陷阱"

Direct ByteBuffer 使用注意事项

项目大量使用 env->GetDirectBufferAddress 实现 Java 与 C++ 之间的高效数据传输。

  • 优势:直接访问 Java 堆外内存,避免数据拷贝,速度极快。
  • 风险
    1. GC 回收问题 :必须确保 Java 层的 Buffer 对象在 C++ 处理期间不被 GC 回收。Media3 的 SimpleDecoder 框架通过维护缓冲队列保证了这一点,但自定义代码中需特别注意。
    2. 空指针检查GetDirectBufferAddress 可能返回 nullptr(如 Buffer 不是 Direct Buffer 或已被回收),使用前必须检查。
    3. 容量检查 :C++ 层无法自动知道 Buffer 的容量,需从 Java 层传入 limitcapacity

JNI 引用管理

  • 局部引用 (Local Reference)
    • 生命周期:当前 JNI 方法调用结束前有效。
    • 限制:默认局部引用表容量有限(通常 512 个),在循环中创建大量局部引用会导致溢出。
    • 解决方案 :在循环中使用 env->DeleteLocalRef(obj) 及时释放。
  • 全局引用 (Global Reference)
    • 生命周期:手动创建和释放,跨 JNI 调用有效。
    • 用途:缓存 Java Class 对象(如 VideoDecoderOutputBuffer 类)。
    • 注意 :必须在 JNI_OnUnload 或适当时机调用 env->DeleteGlobalRef() 释放,否则导致内存泄漏。

代码示例:ffvideo.cpp 中的潜在 Bug

cpp 复制代码
// 以下代码存在 Bug:yuvPlanesU 和 yuvPlanesV 被错误地使用了 yuvPlanesY
auto *planeU = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesY));
auto *planeV = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesY));

// 修复:重新正确获取
planeU = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesU));
planeV = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesV));

B. 性能瓶颈:对齐 (Alignment)

  • 现象 :在 ffvideo.cpp 中,你会看到 ALIGN(native_window_buffer.stride / 2, 16)
  • 原因 :现代 CPU 指令集(如 ARM NEON)处理对齐数据速度远快于非对齐数据。步长不对齐可能会导致:
    • 视频花屏(画面出现条纹或错位)
    • 性能下降(CPU 需要额外处理非对齐内存访问)
    • 在某些架构上甚至会导致崩溃(如 ARM 的严格对齐要求)
  • 对齐规则
    • Y 平面步长:通常对齐到 16 字节
    • UV 平面步长:通常为 Y 步长的一半,也对齐到 16 字节
    • 高度:UV 平面高度为 (height + 1) / 2(向上取整)

C. 线程安全

  • FFmpeg 解码上下文(AVCodecContext)是非线程安全的
  • 核心原则
    1. 严禁在多个线程同时操作同一个解码器实例。
    2. avcodec_send_packet()avcodec_receive_frame() 必须在同一个线程中调用。
    3. 项目中,Media3 的 SimpleDecoder 框架保证了所有解码操作在单线程中执行。
  • SwrContext 和 SwsContext:同样是非线程安全的,不能跨线程共享。

D. Seek 后的解码状态处理

  • 问题:Seek 后,FFmpeg 解码器内部可能残留旧的解码状态,导致首帧解码失败或输出错误数据。

  • 标准处理 :调用 avcodec_flush_buffers() 清空内部缓冲区。

  • 特殊情况:TrueHD 格式不支持简单的 flush,需要销毁并重建解码上下文(见第 5 章 B 节)。

  • 视频 Seek 策略 :在 frame_extractor.cpp 中,Seek 后如果第一帧解码失败,会回退到文件开头重新解码:

    cpp 复制代码
    av_seek_frame(..., seekPosition, 0);
    bool result = read_frame(...);
    if (!result) {
        av_seek_frame(..., 0, 0);  // 回退到开头
        result = read_frame(...);
    }

E. 错误处理与日志

  • FFmpeg 错误码 :FFmpeg 函数通常返回负数表示错误,可使用 av_err2str(result) 转换为可读字符串。
  • 常见错误码
    • AVERROR(EAGAIN):需要更多输入或输出(非致命,正常流程)
    • AVERROR_EOF:文件结束(正常流程)
    • AVERROR_INVALIDDATA:数据损坏(可跳过)
    • AVERROR(ENOMEM):内存不足(致命)
  • 项目日志 :使用 LOGE() 宏输出错误信息,可在 logcat 中过滤 nextlib 标签查看。

F. 缓冲区动态扩展

  • 音频解码的特殊性:某些音频格式(如多声道高采样率)解码后的数据量可能超过预设缓冲区大小。

  • 解决方案 :在 ffaudio.cpp 中,通过 GrowOutputBufferCallback 回调 Java 层的 growOutputBuffer() 方法,动态扩展 Direct ByteBuffer:

    cpp 复制代码
    if (outSize + bufferOutSize > outputSize) {
        outputSize = outSize + bufferOutSize;
        outputBuffer = growBuffer(outputSize);  // 回调 Java 重新分配
    }

8. 音视频开发基础知识 (Audio/Video Development Basics)

本章节为新手开发者提供音视频开发的核心概念和术语,帮助理解 nextlib 的工作原理。

8.1 媒体文件的基本结构

一个完整的媒体文件(如 MP4、MKV)通常包含以下层次:

arduino 复制代码
┌─────────────────────────────────────────────────┐
│              容器 (Container)                     │
│  如: MP4, MKV, AVI, FLV, WebM                    │
│  职责: 组织和管理多个流,提供索引和元数据           │
├─────────────────────────────────────────────────┤
│  ┌───────────┐  ┌───────────┐  ┌──────────────┐  │
│  │ 视频流     │  │ 音频流     │  │ 字幕流        │  │
│  │ (Stream 0)│  │ (Stream 1)│  │ (Stream 2)   │  │
│  └───────────┘  └───────────┘  └──────────────┘  │
│       ↓              ↓              ↓             │
│  ┌───────────┐  ┌───────────┐  ┌──────────────┐  │
│  │ H.264     │  │ AAC       │  │ SRT/ASS      │  │
│  │ HEVC      │  │ FLAC      │  │ WebVTT       │  │
│  │ VP9       │  │ Opus      │  │ PGS          │  │
│  └───────────┘  └───────────┘  └──────────────┘  │
│       ↓              ↓                           │
│  压缩数据 (Encoded)  压缩数据 (Encoded)            │
└─────────────────────────────────────────────────┘

关键概念

  • 容器 (Container/Muxer Format):负责将多个流"打包"在一起的文件格式。容器不关心内容如何编码,只负责组织和索引。
  • 流 (Stream):媒体文件中的独立数据通道,如视频流、音频流、字幕流。
  • 编解码器 (Codec):负责压缩和解压缩数据的算法,如 H.264、AAC、FLAC。

8.2 视频编码基础

为什么需要压缩?

未经压缩的 1080p60 视频数据量约为:

yaml 复制代码
1920 × 1080 × 3 字节/像素 × 60 帧/秒 ≈ 373 MB/秒

这远远超出了存储和传输能力。视频编码通过消除冗余将数据压缩到原来的 1/100 ~ 1/1000。

帧类型 (Frame Types)

帧类型 全称 特点 压缩率 解码依赖
I 帧 Intra-coded Frame 关键帧,完整图像 低 (类似 JPEG) 无依赖,可独立解码
P 帧 Predictive Frame 前向预测帧 依赖前面的 I 帧或 P 帧
B 帧 Bidirectional Frame 双向预测帧 依赖前面和后面的参考帧

GOP (Group of Pictures):两个 I 帧之间的一组帧。GOP 长度影响 Seek 精度和压缩率。

less 复制代码
解码顺序: I → P → B → B → P → B → B → I → ...
显示顺序: I → B → B → P → B → B → P → I → ...

常见视频编解码器

编解码器 标准 特点 应用场景
H.264/AVC MPEG-4 Part 10 广泛兼容,硬件支持好 流媒体、蓝光、视频会议
H.265/HEVC MPEG-H Part 2 比 H.264 压缩率高 50% 4K/8K 视频
VP8 Google 开源,WebM 容器 YouTube、Web 视频
VP9 Google 比 VP8 压缩率高 40% YouTube 4K、WebM
AV1 AOMedia 最新开源,压缩率最高 下一代流媒体标准

像素格式 (Pixel Format)

视频解码后的原始数据通常使用 YUV 格式:

格式 说明 采样方式 每像素字节
YUV420P 平面格式,Y/U/V 分开存储 Y 全采样,U/V 各 1/4 1.5
NV12 半平面格式,Y 平面 + 交错的 UV 同 YUV420P 1.5
NV21 Android 相机默认格式 同 YUV420P,UV 顺序相反 1.5
YV12 Android Surface 要求格式 同 YUV420P,V/U 顺序相反 1.5
RGB888 真彩色 每像素 24 位 3

YUV 的优势

  • 人眼对亮度(Y)敏感,对色彩(U/V)不敏感
  • 降低色彩采样率(4:2:0)可节省 50% 带宽,但视觉差异很小

8.3 音频编码基础

采样参数

参数 说明 常见值
采样率 (Sample Rate) 每秒采样次数 44100 Hz (CD), 48000 Hz (视频)
位深度 (Bit Depth) 每个样本的位数 16 bit (CD), 24 bit (高清)
声道数 (Channels) 独立声道数量 1 (Mono), 2 (Stereo), 6 (5.1)

数据量计算

ini 复制代码
立体声 16bit 44100Hz = 44100 × 2 × 2 = 176,400 字节/秒 ≈ 172 KB/s

音频编码格式分类

类型 格式 特点 码率
无损压缩 FLAC, ALAC, TrueHD 完全保留原始数据 500-1500 kbps
有损压缩 AAC, MP3, Opus, Vorbis 去除人耳不敏感的信息 64-320 kbps
未压缩 PCM, WAV 原始采样数据 1411 kbps (CD)

声道布局 (Channel Layout)

布局 声道 说明
Mono 1.0 单声道
Stereo 2.0 立体声 (左 + 右)
5.1 6 声道 前左 + 前右 + 中置 + 低音 + 后左 + 后右
7.1 8 声道 5.1 + 侧左 + 侧右

8.4 时间戳与同步

时间基准 (Time Base)

FFmpeg 中使用分数表示时间基准:

cpp 复制代码
AVRational time_base = {1, 1000};  // 1/1000 秒 = 1 毫秒
int64_t pts = 5000;  // 5000 个 time_base 单位 = 5000 ms

时间戳换算

cpp 复制代码
// 从 AV_TIME_BASE (微秒) 转换为流的 time_base
int64_t stream_pts = av_rescale_q(time_ms * 1000, AV_TIME_BASE_Q, stream->time_base);

// 从流的 time_base 转换为毫秒
int64_t time_ms = av_rescale_q(stream_pts, stream->time_base, AV_TIME_BASE_Q);

音视频同步 (A/V Sync)

播放器必须确保音频和视频同步播放。Media3 使用音频时钟作为主时钟:

复制代码
视频播放速度 → 跟踪音频时钟 → 如视频快了则等待,慢了则丢帧

常见问题

  • 音画不同步:通常由解码延迟、渲染延迟或时间戳错误引起。
  • 字幕漂移:字幕文件的时间轴与视频帧率不匹配(如 PAL 25fps vs NTSC 23.976fps)。

8.5 容器格式详解

MP4 (MPEG-4 Part 14)

  • 特点:广泛兼容,支持流式播放(Moov Atom 在文件头部时)。
  • 支持的编码:H.264, H.265, AAC, MP3, AC3 等。
  • 限制:不支持某些开源编码(如 VP9、FLAC 需特殊处理)。

MKV (Matroska)

  • 特点:开源、灵活,支持几乎任何编码格式。
  • 优势:支持多音轨、多字幕、章节、附件(字体)。
  • 限制:部分硬件播放器不支持。

WebM

  • 特点:Google 开发的简化版 MKV,专为 Web 设计。
  • 支持的编码:VP8, VP9, AV1 (视频) + Vorbis, Opus (音频)。

8.6 Android 媒体架构

MediaCodec (硬件编解码器)

  • Android 提供的硬件编解码器接口。
  • 优势:性能高、功耗低。
  • 限制:依赖设备芯片,格式支持有限(如不支持 FLAC、Vorbis 软解)。

Media3 (ExoPlayer)

  • Google 开发的新一代播放器框架。
  • 架构ExoPlayerRendererDecoderMediaCodec / FFmpeg
  • 扩展点 :通过 RenderersFactory 可自定义解码器。

nextlib 的定位

scss 复制代码
┌──────────────────────────────────────────────┐
│              应用层 (App)                      │
├──────────────────────────────────────────────┤
│          Media3 (ExoPlayer)                    │
├────────────────┬─────────────────────────────┤
│  系统解码器     │    nextlib FFmpeg 解码器      │
│  (MediaCodec)  │    (软解,支持更多格式)        │
├────────────────┴─────────────────────────────┤
│          FFmpeg 原生库 (.so)                   │
├──────────────────────────────────────────────┤
│          Android NDK / Linux Kernel            │
└──────────────────────────────────────────────┘

9. 常见问题与调试技巧 (FAQ & Debugging Tips)

9.1 编译与构建问题

Q: FFmpeg 编译失败怎么办?

排查步骤

  1. 检查环境变量:ANDROID_NDK_HOMEANDROID_SDK_HOME 是否正确设置。
  2. 检查 CMake 版本:需要 3.22.1 或更高版本。
  3. 查看 ffmpeg/sources/ffmpeg-6.0/config.log 获取详细错误信息。
  4. 清理构建缓存:删除 ffmpeg/buildffmpeg/output 目录后重新编译。

常见错误

  • clang: command not found → NDK 工具链路径错误
  • libvpx not found → 需要先编译 libvpx 依赖
  • mbedtls not found → 需要先编译 mbedtls 依赖

Q: 如何添加新的解码器支持?

步骤

  1. 编辑 ffmpeg/setup.sh,在 ENABLED_DECODERS 变量中添加解码器名称。
  2. 如需要外部库(如 libvpx),添加下载和编译函数。
  3. FfmpegLibrary.javagetCodecName() 中添加 MIME 映射。
  4. 如需要 ExtraData,在 FfmpegVideoDecoder.javaFfmpegAudioDecoder.javagetExtraData() 中添加处理逻辑。
  5. 重新编译:./gradlew :media3ext:assemble

9.2 播放问题

Q: 某些视频无法播放或只有声音没有画面?

可能原因

  1. 解码器未启用 :检查 logcat 中是否有 No xxx decoder available 警告。
  2. ExtraData 缺失 :H.264/H.265 需要 SPS/PPS,检查 getExtraData() 是否正确提取。
  3. 像素格式不支持:某些特殊像素格式(如 10-bit)可能无法正确转换为 YV12。

调试方法

bash 复制代码
# 查看 FFmpeg 支持的解码器列表
adb logcat | grep -i "ffmpeg"

# 检查当前使用的解码器
adb logcat | grep -i "NextRenderersFactory"

Q: 音频爆音或杂音?

可能原因

  1. TrueHD Seek 问题:TrueHD 格式 Seek 后需要重建解码上下文(项目已处理)。
  2. 采样率不匹配 :检查 swr_convert 是否正确配置了输入/输出采样率。
  3. 缓冲区溢出 :检查 growOutputBuffer() 是否正确处理了缓冲区扩展。

Q: 音画不同步?

排查步骤

  1. 检查视频时间戳(PTS)是否正确。
  2. 检查是否有 B 帧解码顺序问题。
  3. 尝试使用 EXTENSION_RENDERER_MODE_PREFER 让 FFmpeg 优先解码。

9.3 JNI 调试

Q: JNI 崩溃 (SIGSEGV) 如何定位?

工具

  1. NDK Stack 解析

    bash 复制代码
    # 使用 ndk-stack 解析崩溃堆栈
    adb logcat | $NDK/ndk-stack -sym $PROJECT_PATH/media3ext/build/intermediates/cmake/debug/obj
  2. AddressSanitizer (ASan)

    kotlin 复制代码
    // build.gradle.kts 中启用 ASan
    externalNativeBuild {
        cmake {
            cppFlags += "-fsanitize=address -fno-omit-frame-pointer"
        }
    }
  3. 常见崩溃原因

    • 空指针解引用(GetDirectBufferAddress 返回 nullptr
    • 数组越界(linesize 计算错误)
    • 已释放的指针(Use-After-Free)
    • JNI 引用表溢出

Q: 如何查看 FFmpeg 内部日志?

方法 :在 ffmain.cpp 中注册 FFmpeg 日志回调:

cpp 复制代码
#include <libavutil/log.h>

void ffmpeg_log_callback(void *ptr, int level, const char *fmt, va_list vl) {
    char line[1024];
    vsnprintf(line, sizeof(line), fmt, vl);
    __android_log_print(ANDROID_LOG_DEBUG, "FFmpeg", "%s", line);
}

JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    av_log_set_callback(ffmpeg_log_callback);
    // ...
}

9.4 性能优化

Q: 如何优化解码性能?

建议

  1. 优先使用硬件解码器 :通过 EXTENSION_RENDERER_MODE_PREFER 让系统解码器优先。
  2. 调整解码线程数FfmpegVideoDecoderthreads 参数可控制 FFmpeg 解码线程数。
  3. 减少缓冲区拷贝:使用 Direct ByteBuffer 避免额外拷贝。
  4. 对齐优化:确保 YUV 步长对齐到 16 字节(项目已处理)。

Q: 内存占用过高?

排查

  1. 检查 Direct ByteBuffer 是否正确释放。
  2. 检查 JNI 全局引用是否泄漏(使用 adb shell dumpsys meminfo <package>)。
  3. 减少输入/输出缓冲区数量(numInputBuffers / numOutputBuffers)。

9.5 缩略图提取问题

Q: getFrameAtTime() 返回空或错误的帧?

可能原因

  1. Seek 精度问题:FFmpeg Seek 到关键帧,可能不是精确时间戳。
  2. 像素格式不支持:某些特殊像素格式无法转换为 RGBA。
  3. 文件损坏 :流信息不完整,avformat_find_stream_info() 失败。

解决方案

  • 尝试 Seek 到文件开头:getFrameAtTime(0)
  • 使用 getFrameAtIndex(0) 获取第一帧
  • 检查 logcat 中的 FFmpeg 错误日志

9.6 调试命令速查

bash 复制代码
# 查看设备 ABI
adb shell getprop ro.product.cpu.abi

# 查看 .so 库是否打包到 APK
unzip -l app-debug.apk | grep libnextlib

# 查看 FFmpeg 版本
adb logcat | grep "FfmpegLibrary"

# 查看解码器使用情况
adb logcat | grep "NextRenderersFactory"

# 查看内存使用
adb shell dumpsys meminfo <package_name>

# 查看 JNI 引用表
adb shell setprop debug.malloc.check_jni 1

# 捕获崩溃堆栈
adb logcat -d > crash.log

附录:参考资源

FFmpeg 官方文档

Media3 (ExoPlayer) 文档

Android NDK 开发

音视频学习资源

  • 《FFmpeg 从入门到精通》
  • 《音视频开发进阶指南:基于 Android 与 iOS 平台的实践》
  • Learn FFmpeg libav the Hard Way:github.com/leandromore...
相关推荐
aq55356003 小时前
Laravel10.x重磅升级,新特性一览
android·java·开发语言
Trouvaille ~3 小时前
【MySQL篇】数据类型:存储数据的基础
android·数据库·mysql·adb·字符集·数据类型·基础入门
2401_885885044 小时前
开发视频短信接口好开发吗?图文视频短信接口对接教程
android·音视频
千码君20166 小时前
kotlin:Jetpack Compose 给APP添加声音(点击音效/背景音乐)
android·开发语言·kotlin·音效·jetpack compose
Fᴏʀ ʏ꯭ᴏ꯭ᴜ꯭.6 小时前
MySQL半同步复制与GTID实战详解
android·mysql·adb
用户41659673693557 小时前
深度解码:记一次视频时间戳(PTS)异常导致的播放故障排查
android
大白菜和MySQL9 小时前
linux系统环境常用命令
android·linux·adb
Ehtan_Zheng9 小时前
彻底告别 AndroidX 依赖:如何在 KMP 中构建 100% 复用的 UI 逻辑层?
android
Hello小赵9 小时前
C语言如何自定义链接库——编译与调用
android·java·c语言