FFmpeg 是音视频瑞士军刀,但在移动端集成——交叉编译、JNI 桥接、Swift 封装,每一步都是坑。本文用 Claude Code 写一个双端统一的 FFmpeg 视频编辑封装,告别命令行拼接。
1、FFmpeg 移动端集成有多痛
“后端说视频编辑用 FFmpeg 命令行就行,但移动端不能直接调命令行…”
移动端集成 FFmpeg 的五个坑:
- 交叉编译 — 你得为 arm64-v8a / x86_64 / simulator-arm64 各编一份 .so
- JNI/桥接 — Android 需要写 JNI 胶水代码,iOS 需要 ObjC/Swift 桥接 C API
- API 地狱 — FFmpeg 的 C API 有上千个函数,文档散落在 doxygen 里
- 内存管理 — C 层的 AVFrame/AVPacket 需要手动 alloc/free,漏一个就是内存泄漏
- 错误处理 — FFmpeg 函数返回负数错误码,你需要逐个翻译成人类可读的错误信息
2、FFmpeg 视频编辑管线
输入文件/流
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 解封装 │ → │ 解码 │ → │ 滤镜处理 │ → │ 编码 │
│ avformat │ │ avcodec │ │ avfilter │ │ avcodec │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐
│ 封装 │
│ avformat │
└──────────┘
│
▼
输出文件/流
| 操作 | FFmpeg 模块 | 关键 API |
|---|---|---|
| 裁剪 | avfilter (crop) | av_buffersrc_add_frame |
| 旋转 | avfilter (transpose/rotate) | avfilter_graph_create_filter |
| 缩放 | libswscale | sws_scale |
| 倍速 | avfilter (setpts/atempo) | PTS 修改 |
| 加水印 | avfilter (overlay) | 双输入滤镜图 |
3、Claude Code 生成跨平台封装
3.1Prompt
帮我为移动端(iOS + Android)写一个 FFmpeg 视频编辑的跨平台封装。
【核心功能】
1. 视频裁剪:指定起始时间 + 时长
2. 视频旋转:90°/180°/270°
3. 视频缩放:指定输出分辨率
4. 倍速播放:0.5x / 1.5x / 2x
5. 操作可组合:裁剪+旋转+缩放一次完成
【C 核心层】ffmpeg_core.h/c
- 纯 C 实现,不依赖平台
- 每个操作返回错误码 + 可读的错误信息
【Android JNI 层】FfmpegEditor.kt + ffmpeg_jni.c
- Kotlin 协程封装,suspend 函数
- Progress 回调
- 取消支持
【iOS 桥接层】FFmpegEditor.swift + FFmpegBridge.mm
- async/await 封装
- Progress 闭包回调
- 取消支持
要求:
- 中文注释
- 单例模式管理 FFmpeg 全局状态
- 处理常见错误:文件不存在、编码器不支持、磁盘满
3.2、C 核心层
// ffmpeg_core.h
// FFmpeg 视频编辑核心 —— 纯 C,跨平台复用
#ifndef FFMPEG_CORE_H
#define FFMPEG_CORE_H
#include <stdint.h>
#ifdef __cplusplus
extern"C" {
#endif
// MARK: - 错误码
typedefenum {
FFEDIT_OK = 0,
FFEDIT_ERR_FILE_OPEN = -1001, // 文件无法打开
FFEDIT_ERR_STREAM = -1002, // 无视频流
FFEDIT_ERR_DECODER = -1003, // 解码器创建失败
FFEDIT_ERR_ENCODER = -1004, // 编码器创建失败
FFEDIT_ERR_FILTER = -1005, // 滤镜创建失败
FFEDIT_ERR_MUXER = -1006, // 封装器创建失败
FFEDIT_ERR_MEMORY = -1007, // 内存不足
FFEDIT_ERR_DISK_FULL = -1008, // 磁盘满
FFEDIT_ERR_CANCELLED = -1009, // 用户取消
FFEDIT_ERR_UNKNOWN = -1999
} FFEditError;
// MARK: - 编辑参数
typedefstruct {
// 裁剪
double trimStartSec; // 起始时间(秒),-1 表示不裁剪
double trimDurationSec; // 裁剪时长(秒),-1 表示到结尾
// 旋转 (0/90/180/270)
int rotationDegrees;
// 缩放 (-1 表示保持原始比例)
int outputWidth;
int outputHeight;
// 倍速 (0.5/1.0/1.5/2.0)
double speedFactor;
// 输出
constchar* outputPath;
constchar* outputFormat; // "mp4" / "mov" / NULL (自动推断)
// 视频编码参数
constchar* videoCodec; // "h264" / "hevc" / NULL (自动选择)
int videoBitrate; // 码率(bps),-1 表示自动
} FFEditParams;
// MARK: - 进度回调
typedef void (*FFEditProgressCallback)(float progress, void* userData);
// MARK: - 取消回调
typedef int (*FFEditCancelCallback)(void* userData); // 返回 1 表示取消
// MARK: - 核心函数
/**
* 视频编辑(同步执行,建议在后台线程调用)
*
* @param inputPath 输入视频路径
* @param params 编辑参数
* @param progressCb 进度回调 (0.0 - 1.0),可为 NULL
* @param cancelCb 取消回调,返回 1 取消,可为 NULL
* @param userData 回调透传数据
* @param errorMsg 错误信息输出(调用者需 free),可为 NULL
* @return 0 成功,负数错误码
*/
int ffedit_process(
const char* inputPath,
const FFEditParams* params,
FFEditProgressCallback progressCb,
FFEditCancelCallback cancelCb,
void* userData,
char** errorMsg
);
/**
* 获取 FFmpeg 版本信息
*/
const char* ffedit_version(void);
/**
* 获取错误码描述
*/
const char* ffedit_strerror(int errorCode);
#ifdef __cplusplus
}
#endif
#endif // FFMPEG_CORE_H
// ffmpeg_core.c (核心处理逻辑 — 关键部分)
#include "ffmpeg_core.h"
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavfilter/avfilter.h>
#include <libavfilter/buffersrc.h>
#include <libavfilter/buffersink.h>
#include <libavutil/opt.h>
#include <libswscale/swscale.h>
#include <string.h>
int ffedit_process(
const char* inputPath,
const FFEditParams* params,
FFEditProgressCallback progressCb,
FFEditCancelCallback cancelCb,
void* userData,
char** errorMsg
) {
int ret = FFEDIT_OK;
AVFormatContext* inputCtx = NULL;
AVFormatContext* outputCtx = NULL;
AVCodecContext* decCtx = NULL;
AVCodecContext* encCtx = NULL;
AVFilterGraph* filterGraph = NULL;
AVFrame* frame = NULL;
AVPacket* pkt = NULL;
int videoStreamIdx = -1;
// 1. 打开输入文件
ret = avformat_open_input(&inputCtx, inputPath, NULL, NULL);
if (ret < 0) {
*errorMsg = format_error("无法打开输入文件", ret);
return FFEDIT_ERR_FILE_OPEN;
}
avformat_find_stream_info(inputCtx, NULL);
// 2. 找到视频流
for (unsigned i = 0; i < inputCtx->nb_streams; i++) {
if (inputCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
videoStreamIdx = i;
break;
}
}
if (videoStreamIdx < 0) {
*errorMsg = strdup("未找到视频流");
ret = FFEDIT_ERR_STREAM;
goto cleanup;
}
// 3. 创建解码器
AVCodecParameters* codecPar = inputCtx->streams[videoStreamIdx]->codecpar;
const AVCodec* decoder = avcodec_find_decoder(codecPar->codec_id);
decCtx = avcodec_alloc_context3(decoder);
avcodec_parameters_to_context(decCtx, codecPar);
avcodec_open2(decCtx, decoder, NULL);
// 4. 构建滤镜图 (裁剪 → 旋转 → 缩放 → 倍速)
char filterDesc[1024] = {0};
buildFilterDescription(params, decCtx, filterDesc, sizeof(filterDesc));
// 例如: "crop=iw:ih-100:0:0,transpose=1,scale=720:1280,setpts=0.5*PTS"
filterGraph = avfilter_graph_alloc();
AVFilterContext* srcCtx = NULL;
AVFilterContext* sinkCtx = NULL;
// 创建 buffer source (输入)
const AVFilter* srcFilter = avfilter_get_by_name("buffer");
avfilter_graph_create_filter(&srcCtx, srcFilter, "in",
buildBufferArgs(decCtx), NULL, filterGraph);
// 创建 buffer sink (输出)
const AVFilter* sinkFilter = avfilter_get_by_name("buffersink");
avfilter_graph_create_filter(&sinkCtx, sinkFilter, "out",
NULL, NULL, filterGraph);
// 解析滤镜描述
AVFilterInOut* inputs = avfilter_inout_alloc();
AVFilterInOut* outputs = avfilter_inout_alloc();
outputs->name = av_strdup("in");
outputs->filter_ctx = srcCtx;
inputs->name = av_strdup("out");
inputs->filter_ctx = sinkCtx;
avfilter_graph_parse_ptr(filterGraph, filterDesc,
&inputs, &outputs, NULL);
avfilter_graph_config(filterGraph, NULL);
// 5. 创建编码器
const AVCodec* encoder = avcodec_find_encoder_by_name("libx264");
encCtx = avcodec_alloc_context3(encoder);
encCtx->width = params->outputWidth > 0 ? params->outputWidth : decCtx->width;
encCtx->height = params->outputHeight > 0 ? params->outputHeight : decCtx->height;
encCtx->time_base = (AVRational){1, 30};
encCtx->pix_fmt = AV_PIX_FMT_YUV420P;
avcodec_open2(encCtx, encoder, NULL);
// 6. 创建输出文件
avformat_alloc_output_context2(&outputCtx, NULL,
params->outputFormat, params->outputPath);
AVStream* outStream = avformat_new_stream(outputCtx, NULL);
avcodec_parameters_from_context(outStream->codecpar, encCtx);
avio_open(&outputCtx->pb, params->outputPath, AVIO_FLAG_WRITE);
avformat_write_header(outputCtx, NULL);
// 7. 主处理循环
frame = av_frame_alloc();
pkt = av_packet_alloc();
int64_t frameCount = 0;
int64_t totalFrames = inputCtx->streams[videoStreamIdx]->nb_frames;
while (av_read_frame(inputCtx, pkt) >= 0) {
// 取消检查
if (cancelCb && cancelCb(userData)) {
ret = FFEDIT_ERR_CANCELLED;
break;
}
if (pkt->stream_index != videoStreamIdx) {
av_packet_unref(pkt);
continue;
}
// 送解码
avcodec_send_packet(decCtx, pkt);
av_packet_unref(pkt);
while (avcodec_receive_frame(decCtx, frame) >= 0) {
// 送滤镜
av_buffersrc_add_frame(srcCtx, frame);
AVFrame* filteredFrame = av_frame_alloc();
if (av_buffersink_get_frame(sinkCtx, filteredFrame) >= 0) {
// 送编码
filteredFrame->pts = frameCount++;
avcodec_send_frame(encCtx, filteredFrame);
AVPacket* outPkt = av_packet_alloc();
while (avcodec_receive_packet(encCtx, outPkt) >= 0) {
av_packet_rescale_ts(outPkt, encCtx->time_base,
outStream->time_base);
av_interleaved_write_frame(outputCtx, outPkt);
av_packet_unref(outPkt);
}
av_packet_free(&outPkt);
}
av_frame_free(&filteredFrame);
// 进度回调
if (progressCb && totalFrames > 0) {
progressCb((float)frameCount / totalFrames, userData);
}
}
}
// 8. 冲刷编码器
avcodec_send_frame(encCtx, NULL);
// ... 接收剩余包 ...
av_write_trailer(outputCtx);
cleanup:
if (inputCtx) avformat_close_input(&inputCtx);
if (outputCtx) {
avio_closep(&outputCtx->pb);
avformat_free_context(outputCtx);
}
if (decCtx) avcodec_free_context(&decCtx);
if (encCtx) avcodec_free_context(&encCtx);
if (filterGraph) avfilter_graph_free(&filterGraph);
if (frame) av_frame_free(&frame);
if (pkt) av_packet_free(&pkt);
return ret;
}
// 构建滤镜描述字符串
static void buildFilterDescription(
const FFEditParams* params,
AVCodecContext* decCtx,
char* out, size_t outSize
) {
char buf[1024] = {0};
int offset = 0;
// 1. 裁剪 (通过 trim filter)
if (params->trimStartSec >= 0) {
offset += snprintf(buf + offset, sizeof(buf) - offset,
"trim=start=%.3f", params->trimStartSec);
if (params->trimDurationSec > 0) {
offset += snprintf(buf + offset, sizeof(buf) - offset,
":duration=%.3f", params->trimDurationSec);
}
offset += snprintf(buf + offset, sizeof(buf) - offset, ",");
}
// 2. 旋转
if (params->rotationDegrees > 0) {
constchar* transpose = NULL;
switch (params->rotationDegrees) {
case90: transpose = "transpose=1"; break; // 顺时针 90°
case180: transpose = "transpose=1,transpose=1"; break;
case270: transpose = "transpose=2"; break; // 逆时针 90°
}
if (transpose) {
offset += snprintf(buf + offset, sizeof(buf) - offset,
"%s,", transpose);
}
}
// 3. 缩放
if (params->outputWidth > 0 && params->outputHeight > 0) {
offset += snprintf(buf + offset, sizeof(buf) - offset,
"scale=%d:%d,", params->outputWidth, params->outputHeight);
}
// 4. 倍速
if (params->speedFactor != 1.0 && params->speedFactor > 0) {
double ptsFactor = 1.0 / params->speedFactor;
offset += snprintf(buf + offset, sizeof(buf) - offset,
"setpts=%.3f*PTS,", ptsFactor);
}
// 去掉末尾逗号
if (buf[offset - 1] == ',') {
buf[offset - 1] = '\0';
}
// 至少保证有一个空滤镜(identity)
if (strlen(buf) == 0) {
snprintf(buf, sizeof(buf), "null");
}
strncpy(out, buf, outSize);
}
3.3、Android Kotlin 封装
// FfmpegEditor.kt
// Android 端 FFmpeg 视频编辑器
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class FfmpegEditor {
companionobject {
init {
System.loadLibrary("ffmpeg_core")
}
}
// 编辑参数
dataclass EditParams(
val trimStartSec: Double = -1.0,
val trimDurationSec: Double = -1.0,
val rotationDegrees: Int = 0, // 0/90/180/270
val outputWidth: Int = -1,
val outputHeight: Int = -1,
val speedFactor: Double = 1.0,
val outputPath: String,
val outputFormat: String = "mp4",
val videoBitrate: Int = -1
)
// 进度
privateval _progress = MutableStateFlow(0f)
val progress: StateFlow<Float> = _progress.asStateFlow()
privatevar isCancelled = false
/**
* 编辑视频(suspend 函数,在协程中调用)
*/
suspendfun edit(inputPath: String, params: EditParams): Result<String> {
return withContext(Dispatchers.IO) {
isCancelled = false
_progress.value = 0f
val result = nativeEdit(
inputPath,
params.trimStartSec,
params.trimDurationSec,
params.rotationDegrees,
params.outputWidth,
params.outputHeight,
params.speedFactor,
params.outputPath,
params.outputFormat,
params.videoBitrate
)
if (result.errorCode == 0) {
Result.success(params.outputPath)
} else {
Result.failure(FfmpegException(result.errorCode, result.errorMsg))
}
}
}
fun cancel() {
isCancelled = true
}
// JNI
privateexternalfun nativeEdit(
inputPath: String,
trimStart: Double,
trimDuration: Double,
rotation: Int,
outWidth: Int,
outHeight: Int,
speed: Double,
outputPath: String,
outputFormat: String,
videoBitrate: Int
): NativeResult
// 进度回调 —— 由 native 层调用
@Suppress("unused")
privatefun onProgress(percent: Float) {
_progress.value = percent
}
// 取消检查 —— 由 native 层调用
@Suppress("unused")
privatefun onCancelCheck(): Int {
returnif (isCancelled) 1else0
}
dataclass NativeResult(val errorCode: Int, val errorMsg: String)
}
class FfmpegException(val errorCode: Int, msg: String) : Exception(msg)
3.4、iOS Swift 封装
import Foundation
// MARK: - FFmpeg 编辑器(iOS)
finalclass FFmpegEditor: ObservableObject {
staticlet shared = FFmpegEditor()
@Publishedvar progress: Float = 0
@Publishedvar isProcessing = false
privatevar cancelFlag = false
// MARK: - 编辑参数
struct EditParams {
var trimStart: TimeInterval = -1
var trimDuration: TimeInterval = -1
var rotation: Int = 0
var outputWidth: Int = -1
var outputHeight: Int = -1
var speedFactor: Double = 1.0
var outputPath: String
var outputFormat: String = "mp4"
var videoBitrate: Int = -1
}
// MARK: - 编辑(async/await)
func edit(inputPath: String, params: EditParams) async throws -> String {
await MainActor.run {
isProcessing = true
progress = 0
cancelFlag = false
}
returntry await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async { [weakself] in
guardletself = selfelse { return }
var errorMsg: UnsafeMutablePointer<CChar>? = nil
// 转换参数为 C 结构体
var cParams = FFEditParams()
cParams.trimStartSec = params.trimStart
cParams.trimDurationSec = params.trimDuration
cParams.rotationDegrees = Int32(params.rotation)
cParams.outputWidth = Int32(params.outputWidth)
cParams.outputHeight = Int32(params.outputHeight)
cParams.speedFactor = params.speedFactor
cParams.outputPath = (params.outputPath asNSString).utf8String
cParams.outputFormat = (params.outputFormat asNSString).utf8String
cParams.videoBitrate = Int32(params.videoBitrate)
// 进度回调
let progressCb: FFEditProgressCallback = { p, ctx in
let slf = Unmanaged<FFmpegEditor>
.fromOpaque(ctx!).takeUnretainedValue()
DispatchQueue.main.async {
slf.progress = p
}
}
// 取消回调
let cancelCb: FFEditCancelCallback = { ctx in
let slf = Unmanaged<FFmpegEditor>
.fromOpaque(ctx!).takeUnretainedValue()
return slf.cancelFlag ? 1 : 0
}
let ctx = Unmanaged.passUnretained(self).toOpaque()
let ret = ffedit_process(
inputPath,
&cParams,
progressCb,
cancelCb,
ctx,
&errorMsg
)
DispatchQueue.main.async { self.isProcessing = false }
if ret == 0 {
continuation.resume(returning: params.outputPath)
} else {
let msg = errorMsg.map { String(cString: $0) } ?? "未知错误 \(ret)"
free(errorMsg)
continuation.resume(
throwing: FFmpegError(code: Int(ret), message: msg)
)
}
}
}
}
func cancel() {
cancelFlag = true
}
}
// MARK: - 错误类型
struct FFmpegError: LocalizedError {
let code: Int
let message: String
var errorDescription: String? { "[FFmpeg \(code)] \(message)" }
}
// MARK: - 使用示例
// let editor = FFmpegEditor.shared
// let params = FFmpegEditor.EditParams(
// outputPath: outputURL.path, outputFormat: "mp4"
// )
// let result = try await editor.edit(inputPath: inputURL.path, params: params)
4、集成注意事项
4.1、Android FFmpeg 依赖
// build.gradle.kts
dependencies {
// 使用移动端优化的 FFmpeg 预编译库
// 推荐:mobile-ffmpeg(已停维)→ ffmpeg-kit(继任者)
implementation("com.arthenica:ffmpeg-kit-full:6.0-2")
}
4.2、iOS FFmpeg 集成
# Podfile
pod 'ffmpeg-kit-ios-full', '~> 6.0'
或手动编译(Claude Code 可以帮你写编译脚本)。
5、踩坑
| 坑 | 修复 |
|---|---|
avformat_write_header 失败无错误信息 | 用 av_err2str(ret) 格式化错误码 |
sws_scale 后画面颜色偏移 | 检查 YUV 范围:AVCOL_RANGE_JPEG vs AVCOL_RANGE_MPEG |
滤镜 trim + setpts 组合导致音视频不同步 | 音频也要做对应的 atempo 滤镜 |
av_interleaved_write_frame 返回 EAGAIN | 需要循环重试,直到返回 >= 0 |
iOS 上 ffedit_process 阻塞主线程 | 必须用 DispatchQueue.global 或 Swift async Task |
学习和提升音视频开发技术,欢迎你加入我们的知识星球

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。