概述
视频文件在日常使用中经常面临体积过大的问题:手机录制的 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)。处理流程是:
- 用
SwrContext做采样格式转换和采样率重采样 - 转换后的样本写入
AVAudioFifo - FIFO 攒够一个
frame_size的样本后,取出并送入编码器 - 尾部不足一帧的样本在 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.qml 的 refreshVideoCompressRows() 从 compressVideoInfo 映射生成。
状态清理
当前文件切换或重新导入时,MediaAnalyzer::openFile() 会调用 setCompressVideoInfo(QVariantMap()) 清空旧的压缩结果,避免 UI 显示过期数据。QML 页面通过 Connections 监听 onCurrentFileChanged 和 onFileOpened 信号,同步刷新默认输出路径。
小结
视频一键压缩的实现要点:
- 分层架构 :
VideoTranscoder(纯算法)→MediaAnalyzer(状态 + 异步调度)→VideoCompressPage.qml(交互),职责分离清晰。 - CRF 驱动:不暴露码率参数,用 CRF 控制质量,让编码器自行分配码率,对用户更友好。
- 条件缩放:只在目标分辨率与源不一致时初始化 sws_scale,原始尺寸模式零额外开销。
- AudioFIFO 攒帧 :解决解码帧大小与编码器
frame_size不匹配的问题,尾部样本在 flush 阶段补齐。 - 三档预设:把复杂的编码参数收敛为三个直觉化选项,降低使用门槛。