移动端视频裁剪/旋转/缩放:Claude Code 写跨平台 FFmpeg 封装

FFmpeg 是音视频瑞士军刀,但在移动端集成——交叉编译、JNI 桥接、Swift 封装,每一步都是坑。本文用 Claude Code 写一个双端统一的 FFmpeg 视频编辑封装,告别命令行拼接。

1、FFmpeg 移动端集成有多痛

“后端说视频编辑用 FFmpeg 命令行就行,但移动端不能直接调命令行…”

移动端集成 FFmpeg 的五个坑:

  1. 交叉编译 — 你得为 arm64-v8a / x86_64 / simulator-arm64 各编一份 .so
  2. JNI/桥接 — Android 需要写 JNI 胶水代码,iOS 需要 ObjC/Swift 桥接 C API
  3. API 地狱 — FFmpeg 的 C API 有上千个函数,文档散落在 doxygen 里
  4. 内存管理 — C 层的 AVFrame/AVPacket 需要手动 alloc/free,漏一个就是内存泄漏
  5. 错误处理 — FFmpeg 函数返回负数错误码,你需要逐个翻译成人类可读的错误信息

2、FFmpeg 视频编辑管线

输入文件/流
    │
    ▼
┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│ 解封装    │ →  │ 解码      │ →  │ 滤镜处理   │ →  │ 编码      │
│ avformat  │    │ avcodec   │    │ avfilter   │    │ avcodec   │
└──────────┘    └──────────┘    └──────────┘    └──────────┘
                                                      │
                                                      ▼
                                                ┌──────────┐
                                                │ 封装      │
                                                │ avformat  │
                                                └──────────┘
                                                      │
                                                      ▼
                                                 输出文件/流
操作FFmpeg 模块关键 API
裁剪avfilter (crop)av_buffersrc_add_frame
旋转avfilter (transpose/rotate)avfilter_graph_create_filter
缩放libswscalesws_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

学习和提升音视频开发技术,欢迎你加入我们的知识星球

移动端视频裁剪/旋转/缩放:Claude Code 写跨平台 FFmpeg 封装

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

(0)

相关推荐