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

概述

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

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

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

模式CRF分辨率音频码率适用场景
清晰优先18原始192 kbps存档、高画质需求
平衡23原始128 kbps日常推荐
小体积28720p (宽 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对齐:

// 计算目标分辨率:宽高自动计算并对齐偶数voidcomputeTargetResolution(intsourceWidth,intsourceHeight,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;}// 只指定了一个维度,按原始宽高比计算另一个维度constdoubleaspect=(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 依赖、无当前文件依赖。它只接收输入路径和编码参数,输出压缩后的视频文件。

类声明

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

编码器参数配置

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

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 转换。条件触发——只有当源分辨率或像素格式与编码器不一致时才初始化缩放上下文,避免不必要的内存分配:

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;}

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

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 中的数据及时被编码写出:

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}elseif(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:

boolMediaAnalyzer::compressCurrentVideo(constQString&filePath,intcrf,inttargetWidth,inttargetHeight,intaudioBitrateKbps,boolkeepAudio,boolcopyMetadata){if(m_currentFile.isEmpty()){setStatus(QStringLiteral("请先导入视频文件"));setCompressVideoInfo(QVariantMap());returnfalse;}// 处理输出路径:支持 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;}returnr;},[this](constAsyncResult&r){if(r.ok){setCompressVideoInfo(r.info);setStatus(QStringLiteral("视频压缩完成:%1").arg(r.outputPath));}else{setCompressVideoInfo(QVariantMap());setStatus(QStringLiteral("视频压缩失败:%1").arg(r.errorText));}});returntrue;}

QML 页面:三档预设选择

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

// 压缩预设定义 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. 三档预设:把复杂的编码参数收敛为三个直觉化选项,降低使用门槛。