Qt + FFmpeg 视频工具:视频一键压缩功能实现

概述

视频文件在日常使用中经常面临体积过大的问题:手机录制的 4K 视频、屏幕录制的原始文件、从网上下载的高码率素材,动辄几百 MB 甚至几个 GB。对于非专业用户来说,打开 HandBrake 或 FFmpeg 命令行去调参数是一件有门槛的事情。

"一键压缩"功能要解决的问题是:让用户不需要理解 CRF、preset、码率、分辨率这些概念,只需在三个预设模式中选择一个,点一下按钮就能完成视频压缩。

三档预设覆盖了三类典型场景:

模式 CRF 分辨率 音频码率 适用场景
清晰优先 18 原始 192 kbps 存档、高画质需求
平衡 23 原始 128 kbps 日常推荐
小体积 28 720p (宽 1280) 96 kbps 手机分享、网盘存储

核心思路:底层是完整的 FFmpeg 转码管线(解码 → 可选 sws_scale 缩放 → H.264 重编码 → AAC 音频 → MP4 封装),上层通过 VideoTranscoder 纯工具类暴露 CRF、分辨率、音频码率等参数,MediaAnalyzer 负责异步调度和状态管理,QML 页面只负责预设选择和结果展示。

实测数据:
原始文件 298MB,三种压缩模式输出文件分别是:151MB、52.8MB、7.8MB


功能流程

复制代码
用户选择预设模式(清晰优先 / 平衡 / 小体积)
        │
        ▼
QML 页面调用 mediaAnalyzer.compressCurrentVideo(filePath, crf, w, h, ...)
        │
        ▼
MediaAnalyzer 入口校验 → 清空旧结果 → setBusy(true) → runAsync()
        │
        ▼
后台线程:VideoTranscoder::transcode()
        │
        ├─ 打开输入容器,找到最佳视频/音频流
        ├─ 创建输出容器,配置 H.264 编码器(CRF + preset medium)
        ├─ 配置音频编码器(AAC + 目标码率)
        ├─ 如目标分辨率 ≠ 源分辨率,初始化 sws_scale 缩放上下文
        ├─ 如音频采样格式/采样率不匹配,初始化 SwrContext 重采样
        ├─ 主循环:av_read_frame → send_packet → receive_frame → 编码 → write_frame
        ├─ Flush 解码器 + 编码器内部缓存
        ├─ 写 trailer,关闭容器
        └─ 组装 transcodeInfo(编码器、分辨率、压缩率、耗时等)
        │
        ▼
主线程回调:setCompressVideoInfo(info) → 状态栏 + InfoPanel 更新

核心算法

CRF 恒定质量因子

CRF(Constant Rate Factor)是 x264/x265 编码器最核心的质量控制参数,范围 0~51:

  • 0:无损编码,体积甚至可能大于源文件
  • 18:视觉无损,人眼几乎看不出与源文件的差异
  • 23:x264 默认值,画质与体积的平衡点
  • 28:明显降低体积,画质有一定损失但可接受
  • 51:最差质量,基本不可用

一键压缩通过暴露三个离散的 CRF 值,让用户在"画质"和"体积"之间做简单选择,而不是面对一个滑块不知所措。

分辨率缩放与偶数对齐

"小体积"模式会将宽度降到 1280(720p),高度按源视频宽高比自动计算。H.264 编码器要求宽高必须是偶数,因此计算结果需要 & ~1 对齐:

cpp 复制代码
// 计算目标分辨率:宽高自动计算并对齐偶数
void computeTargetResolution(int sourceWidth, int sourceHeight,
                             int &targetWidth, int &targetHeight)
{
    if (targetWidth <= 0 && targetHeight <= 0) {
        // 两个维度都未指定,保留原始尺寸
        targetWidth = sourceWidth;
        targetHeight = sourceHeight;
        return;
    }

    if (targetWidth > 0 && targetHeight > 0) {
        // 两个维度都指定了,直接使用(对齐偶数)
        targetWidth = targetWidth & ~1;
        targetHeight = targetHeight & ~1;
        return;
    }

    // 只指定了一个维度,按原始宽高比计算另一个维度
    const double aspect = (sourceHeight > 0)
        ? static_cast<double>(sourceWidth) / sourceHeight : 1.0;
    if (targetWidth > 0) {
        targetWidth = targetWidth & ~1;
        targetHeight = static_cast<int>(targetWidth / aspect + 0.5);
        targetHeight = (targetHeight > 0) ? (targetHeight & ~1) : 2;
    } else {
        targetHeight = targetHeight & ~1;
        targetWidth = static_cast<int>(targetHeight * aspect + 0.5);
        targetWidth = (targetWidth > 0) ? (targetWidth & ~1) : 2;
    }
}

音频 AudioFIFO + SwrContext

解码出的音频帧采样格式和采样率往往与 AAC 编码器要求不一致(例如源是 fltp/48kHz,AAC 需要 fltp/44.1kHz)。处理流程是:

  1. SwrContext 做采样格式转换和采样率重采样
  2. 转换后的样本写入 AVAudioFifo
  3. FIFO 攒够一个 frame_size 的样本后,取出并送入编码器
  4. 尾部不足一帧的样本在 flush 阶段补齐编码

这个模式保证了无论源音频参数如何,编码器始终收到格式正确、大小一致的帧。

后端工具类:VideoTranscoder

VideoTranscoder 是纯工具类,无状态、无 UI 依赖、无当前文件依赖。它只接收输入路径和编码参数,输出压缩后的视频文件。

类声明

cpp 复制代码
class VideoTranscoder
{
public:
    // 对整个视频执行转码压缩
    //
    // crf: 恒定质量因子 0~51,值越小画质越高
    // targetWidth/targetHeight: 目标分辨率,都为 0 时保留原始尺寸
    // audioBitrateKbps: 音频码率(kbps),0 表示编码器默认
    // keepAudio: 是否保留音频流
    // copyMetadata: 是否复制 metadata
    // transcodeInfo: 成功时返回结果摘要
    bool transcode(const QString &inputPath,
                   const QString &outputPath,
                   int crf,
                   int targetWidth,
                   int targetHeight,
                   int audioBitrateKbps,
                   bool keepAudio,
                   bool copyMetadata,
                   QVariantMap *transcodeInfo,
                   QString *errorText) const;
};

编码器参数配置

视频编码器的参数配置是压缩质量的关键。preset medium 保持编码速度和压缩率的平衡,max_b_frames = 0 简化 PTS/DTS 顺序避免 B 帧重排问题,gop_size 按实际帧率动态计算:

cpp 复制代码
videoEncCtx->width = encWidth;
videoEncCtx->height = encHeight;
videoEncCtx->pix_fmt = chooseVideoPixelFormat(videoEncoder, videoDecCtx->pix_fmt);
videoEncCtx->time_base = videoTimeBase;
videoEncCtx->framerate = frameRate;
videoEncCtx->gop_size = qMax(12, frameRate.num > 0 && frameRate.den > 0
    ? frameRate.num / frameRate.den : 12);
videoEncCtx->max_b_frames = 0;

// CRF 和 preset 只对 x264/x265 等支持私有选项的编码器生效
av_opt_set(videoEncCtx->priv_data, "preset", "medium", 0);
av_opt_set_int(videoEncCtx->priv_data, "crf", safeCrf, 0);

视频帧解码 → 缩放 → 编码

每解码一帧后,检查是否需要 sws_scale 转换。条件触发------只有当源分辨率或像素格式与编码器不一致时才初始化缩放上下文,避免不必要的内存分配:

cpp 复制代码
if (videoDecCtx->width != videoEncCtx->width
    || videoDecCtx->height != videoEncCtx->height
    || videoDecCtx->pix_fmt != videoEncCtx->pix_fmt) {
    if (!swsCtx) {
        swsCtx = sws_getContext(
            videoDecCtx->width, videoDecCtx->height, videoDecCtx->pix_fmt,
            videoEncCtx->width, videoEncCtx->height, videoEncCtx->pix_fmt,
            SWS_BILINEAR, nullptr, nullptr, nullptr);
    }
    // 分配可写的编码帧缓冲
    av_frame_make_writable(videoEncodeFrame);
    sws_scale(swsCtx,
              decodedFrame->data, decodedFrame->linesize, 0, videoDecCtx->height,
              videoEncodeFrame->data, videoEncodeFrame->linesize);
    frameForEncoder = videoEncodeFrame;
}

时间戳处理方面,整视频转码不需要裁剪偏移,第一帧直接作为时间轴零点:

cpp 复制代码
if (firstVideoPts == AV_NOPTS_VALUE)
    firstVideoPts = framePts != AV_NOPTS_VALUE ? framePts : 0;

frameForEncoder->pts = framePts != AV_NOPTS_VALUE
    ? av_rescale_q(framePts - firstVideoPts, inVideoStream->time_base, videoEncCtx->time_base)
    : encodedVideoFrames;

主读取循环

标准的 send/receive 模型:逐 packet 读取,按流索引分发到对应解码器,取出帧后立即编码。视频和音频交替处理,保证 FIFO 中的数据及时被编码写出:

cpp 复制代码
while (!reachedEof) {
    ret = av_read_frame(inCtx, packet);
    if (ret == AVERROR_EOF) { reachedEof = true; break; }

    if (packet->stream_index == videoStreamIndex) {
        avcodec_send_packet(videoDecCtx, packet);
        receiveVideoFrames();   // 解码 → 缩放 → 编码 → write_frame
    } else if (audioDecCtx && packet->stream_index == audioStreamIndex) {
        avcodec_send_packet(audioDecCtx, packet);
        receiveAudioFrames();   // 解码 → 重采样 → FIFO → 编码 → write_frame
    }
    av_packet_unref(packet);
}

// Flush 解码器和编码器内部缓存
avcodec_send_packet(videoDecCtx, nullptr);
receiveVideoFrames();
avcodec_send_packet(audioDecCtx, nullptr);
receiveAudioFrames();
// Flush SwrContext 内部延迟样本 → FIFO → 编码

导出封装:MediaAnalyzer 异步调度

MediaAnalyzer 作为 QML 门面层,负责入口校验、路径处理、异步调度和状态更新。转码是耗时操作,必须放到后台线程,避免阻塞 UI:

cpp 复制代码
bool MediaAnalyzer::compressCurrentVideo(const QString &filePath,
                                         int crf, int targetWidth, int targetHeight,
                                         int audioBitrateKbps, bool keepAudio, bool copyMetadata)
{
    if (m_currentFile.isEmpty()) {
        setStatus(QStringLiteral("请先导入视频文件"));
        setCompressVideoInfo(QVariantMap());
        return false;
    }

    // 处理输出路径:支持 file:// URL 和普通路径
    QString outputPath = filePath.trimmed();
    if (outputPath.startsWith(QStringLiteral("file:"), Qt::CaseInsensitive))
        outputPath = QUrl(outputPath).toLocalFile();
    if (outputPath.isEmpty())
        outputPath = defaultCompressedVideoPath();
    if (QFileInfo(outputPath).suffix().isEmpty())
        outputPath += QStringLiteral(".mp4");

    setCompressVideoInfo(QVariantMap());
    setBusy(true);
    setStatus(QStringLiteral("正在压缩视频..."));

    // 转码是耗时操作,必须放到后台线程执行
    runAsync([=]() -> AsyncResult {
        AsyncResult r;
        QVariantMap result;
        r.ok = transcoder.transcode(sourceFile, outputPath, crf,
                                    targetWidth, targetHeight, audioBitrateKbps,
                                    keepAudio, copyMetadata, &result, &r.errorText);
        if (r.ok) { r.info = result; r.outputPath = outputPath; }
        return r;
    },
    [this](const AsyncResult &r) {
        if (r.ok) {
            setCompressVideoInfo(r.info);
            setStatus(QStringLiteral("视频压缩完成:%1").arg(r.outputPath));
        } else {
            setCompressVideoInfo(QVariantMap());
            setStatus(QStringLiteral("视频压缩失败:%1").arg(r.errorText));
        }
    });

    return true;
}

QML 页面:三档预设选择

VideoCompressPage.qml 用三个可点击的预设卡片横排展示,用户点选后高亮当前预设。每个预设包含名称、CRF 标签、描述文本,底部显示当前参数摘要:

qml 复制代码
// 压缩预设定义
property var compressPresets: [
    { name: "清晰优先", crf: 18, targetWidth: 0, targetHeight: 0,
      audioBitrateKbps: 192,
      description: "CRF 18,保留原始分辨率,画质接近无损。" },
    { name: "平衡", crf: 23, targetWidth: 0, targetHeight: 0,
      audioBitrateKbps: 128,
      description: "CRF 23,保留原始分辨率,画质与体积均衡。" },
    { name: "小体积", crf: 28, targetWidth: 1280, targetHeight: 0,
      audioBitrateKbps: 96,
      description: "CRF 28,分辨率降至 720p,适合手机分享。" }
]
property int currentPresetIndex: 1  // 默认"平衡"

// 开始压缩按钮
ActionButton {
    text: "开始压缩"
    accent: true
    onClicked: mediaAnalyzer.compressCurrentVideo(
        outputPathField.text.trim(),
        currentPreset().crf,
        currentPreset().targetWidth,
        currentPreset().targetHeight,
        keepAudioBox.checked ? currentPreset().audioBitrateKbps : 0,
        keepAudioBox.checked,
        copyMetadataBox.checked)
}

结果面板通过 InfoPanel 展示压缩结果,包含编码器、分辨率、压缩率、耗时等信息,数据由 MainWindow.qmlrefreshVideoCompressRows()compressVideoInfo 映射生成。

状态清理

当前文件切换或重新导入时,MediaAnalyzer::openFile() 会调用 setCompressVideoInfo(QVariantMap()) 清空旧的压缩结果,避免 UI 显示过期数据。QML 页面通过 Connections 监听 onCurrentFileChangedonFileOpened 信号,同步刷新默认输出路径。

小结

视频一键压缩的实现要点:

  1. 分层架构VideoTranscoder(纯算法)→ MediaAnalyzer(状态 + 异步调度)→ VideoCompressPage.qml(交互),职责分离清晰。
  2. CRF 驱动:不暴露码率参数,用 CRF 控制质量,让编码器自行分配码率,对用户更友好。
  3. 条件缩放:只在目标分辨率与源不一致时初始化 sws_scale,原始尺寸模式零额外开销。
  4. AudioFIFO 攒帧 :解决解码帧大小与编码器 frame_size 不匹配的问题,尾部样本在 flush 阶段补齐。
  5. 三档预设:把复杂的编码参数收敛为三个直觉化选项,降低使用门槛。
相关推荐
雪的季节2 小时前
ffmpeg源码国内gitee下载
ffmpeg·gitee
blueman88882 小时前
Qt5通过vcpkg中调用时,在debug模式下调试时总是调用release的plugins文件夹中的dll
c++·qt·cmake
中微极客9 小时前
Kling AI 3.0:AI视频生成的工程化跃迁
人工智能·音视频
南国韭菜10 小时前
【无标题】
c++·qt
旋律翼210 小时前
Qt Bridges for C# 深度技术解析
开发语言·qt·c#
开开心心就好10 小时前
免费格式转换工具视频音频文档都能转
人工智能·智能手机·ocr·电脑·音视频·散列表·启发式算法
USB_ABC11 小时前
南京军区训练基地:混合矩阵赋能军事训练智慧化协同升级
矩阵·音视频
中微极客13 小时前
Seedance 2.5深度解析:30秒4K视频生成与50路多模态参考的工程实践
人工智能·音视频
sweetone13 小时前
一台磁带随身听的摩机(查出LAG668芯片低音差的原因)
经验分享·音视频