基于Qt + FFmpeg实现视频片段裁剪功能
作者:luoyayun361
前言
视频片段裁剪用于从一个视频文件中选择一段时间范围,并导出成新的短视频文件。当前实现的是 快速裁剪 / Stream Copy 路线:
导入视频 -> VideoTrimPage 自动生成时间轴缩略图 -> VideoTrimEditor 维护选区、播放头和预览 -> 可选生成轻量音频峰值波形 -> MediaAnalyzer 后台调度导出 -> VideoClipProcessor 使用 FFmpeg stream copy 写出片段
当前版本不做视频帧级重编码,因此导出速度快、画质无损,但起点会按关键帧附近对齐。这是 stream copy 的正常限制,不是时间轴计算错误。
效果图:

一、整体架构
VideoTrimPage.qml
页面层:当前文件、缩略图刷新、音频峰值生成、导出参数和结果展示
↓
VideoTrimEditor.qml
交互层:视频预览、缩略图时间轴、播放头、选区左右把柄、音频峰值轨
↓ selectionStartMs / selectionEndMs
MediaAnalyzer
QML 门面:文件导入通知、异步缩略图、异步音频峰值、异步导出、状态回写
↓
VideoFrameExtractor / AudioDecoder / VideoClipProcessor
FFmpeg 层:批量抽帧、流式音频峰值聚合、stream copy 裁剪导出
二、页面入口:VideoTrimPage.qml
VideoTrimPage.qml 是裁剪功能入口,负责:
- 展示当前视频文件;
- 自动生成或手动刷新时间轴缩略图;
- 按需生成音频峰值波形;
- 选择输出封装;
- 控制是否保留音频、字幕、元数据;
- 调用
trimCurrentVideoCopy()执行导出。
2.1 输出封装和提示
页面当前暴露常见视频封装:
property var outputFormats: ["mp4", "mkv", "mov", "webm"] property string exportModeNotice: "快速导出使用 stream copy,无重编码、画质无损,但起点按关键帧对齐。"
实际能否写入成功仍取决于源流编码和目标容器兼容性。例如部分字幕流不能直接写入 MP4,此时可以取消字幕或改用 MKV。
2.2 缩略图生成触发
页面使用独立缩略图状态,不复用全局 busy:
function generateThumbnails() {
if (!mediaAnalyzer.videoTrimThumbnailBusy && appRoot.hasFile && appRoot.currentFileIsVideo())
mediaAnalyzer.generateVideoTrimThumbnails(24, 180)
}
这里的参数含义:
| 参数 | 含义 |
|---|---|
24 | 默认生成 24 张时间轴缩略图 |
180 | 单张缩略图目标宽度 |
页面进入时会启动一个短延迟定时器:
Timer {
id: autoGenerateThumbnailsTimer
interval: 50
repeat: false
onTriggered: page.generateThumbnails()
}
这个延迟用于等待 openFile() 完成旧数据清理,避免旧缩略图任务或旧列表影响新文件。
2.3 重复导入相同文件
重复导入同一路径时,currentFile 字符串没有变化,currentFileChanged 不会触发。为此 MediaAnalyzer::openFile() 每次成功导入都会发出:
emit fileOpened();
页面同时监听 currentFileChanged 和 fileOpened:
Connections {
target: mediaAnalyzer
function onCurrentFileChanged() {
page.refreshDefaultPath()
autoGenerateThumbnailsTimer.restart()
}
function onFileOpened() {
page.refreshDefaultPath()
trimEditor.resetForImportedFile()
autoGenerateThumbnailsTimer.restart()
}
}
这样即使重复导入同一个视频,也会:
- 重置编辑器旧播放状态;
- 清空旧选区状态;
- 重新触发缩略图生成。
2.4 音频峰值波形
裁剪页不再通过 decodeCurrentToPcm() 解码完整 PCM,而是调用:
mediaAnalyzer.generateVideoTrimAudioPeaks(4096)
4096 表示把整段音频压缩成 4096 个峰值桶。每个桶保存一组 qint16 min/max,数据量约为:
4096 * 2 * sizeof(qint16) = 16KB
这比完整 PCM 更适合大视频文件,避免 1GB 级视频生成波形时给 UI 和内存造成压力。
2.5 编辑器组合
页面把视频、缩略图、缩略图进度、峰值数据传给 VideoTrimEditor:
VideoTrimEditor {
id: trimEditor
source: mediaAnalyzer.currentFileUrl
thumbnails: mediaAnalyzer.videoTrimThumbnails
thumbnailsLoading: mediaAnalyzer.videoTrimThumbnailBusy
thumbnailProgress: mediaAnalyzer.videoTrimThumbnailProgress
thumbnailTotal: mediaAnalyzer.videoTrimThumbnailTotal
audioPeakData: mediaAnalyzer.videoTrimAudioPeakData
audioPeakCount: mediaAnalyzer.videoTrimAudioPeakCount
audioPcmData: mediaAnalyzer.videoTrimAudioPeakCount > 0 ? "" : mediaAnalyzer.pcmData
}
当已有轻量峰值数据时,不再把完整 PCM 传入音频轨,避免其它页面生成过的大 PCM 影响裁剪页绘制性能。
2.6 导出调用
点击“快速导出”时,页面把编辑器当前选区传给后端:
mediaAnalyzer.trimCurrentVideoCopy(Math.round(trimEditor.selectionStartMs),
Math.round(trimEditor.selectionEndMs),
outputPathField.text.trim(),
formatBox.currentText,
keepAudioBox.checked,
keepSubtitleBox.checked,
copyMetadataBox.checked)
页面只读取 selectionStartMs 和 selectionEndMs,不直接处理 FFmpeg 或 packet。
三、编辑器交互:VideoTrimEditor.qml
VideoTrimEditor.qml 是裁剪页的主要交互组件。
3.1 选区和播放头状态
编辑器以毫秒为唯一数据源:
property real selectionStartMs: 0 property real selectionEndMs: 0 property real playheadMs: displayPositionMs property real displayPositionMs: 0 readonly property real durationMs: player.duration > 0 ? player.duration : inferredDuration() readonly property bool hasSelection: selectionEndMs > selectionStartMs
坐标和时间通过线性函数互转:
function msForX(x) { ... }
function xForMs(ms) { ... }
所有可视元素,包括选区矩形、左右暗区、左右把柄、播放头和音频轨,都绑定这套毫秒状态。
3.2 默认选中整段视频
当前实现中,打开视频后默认选区是整段视频:
selectionStartMs = 0 selectionEndMs = durationMs
缩略图是渐进生成的,durationMs 可能先后来自 MediaPlayer.duration 或缩略图携带的 durationMs。只要用户还没有手动拖动把柄,默认全选会跟随最新时长自动扩展。
3.3 重复导入重置
重复导入同一路径时,MediaPlayer.source 不变化,因此 onSourceChanged 不会触发。编辑器提供:
function resetForImportedFile() {
playAfterSeekTimer.stop()
player.stop()
userPlaybackRequested = false
userAdjustedSelection = false
playbackFinished = false
pendingSeek = false
pendingSeekMs = 0
selectionStartMs = 0
selectionEndMs = durationMs > 0 ? durationMs : 0
setDisplayPosition(0)
if (player.seekable)
player.seek(0)
selectionChanged(selectionStartMs, selectionEndMs)
}
这个函数由 VideoTrimPage.qml 在 fileOpened() 信号里调用,用来清理旧播放、旧 seek、旧选区和旧播放完成状态。
3.4 只允许左右把柄调整选区
当前交互规则:
- 默认选中整段;
- 不允许在时间轴空白区域拖拽创建新选区;
- 只能拖动选区左右把柄改变范围;
- 点击选区外不做任何事;
- 点击选区内只定位播放头,不改变选区。
左右把柄调用:
editor.editSelectionFromTimeline(newStartMs, editor.selectionEndMs) editor.editSelectionFromTimeline(editor.selectionStartMs, newEndMs)
setSelection() 会判断当前播放头是否仍在新选区内:
var playheadStillInside = currentPlayhead >= selectionStartMs && currentPlayhead <= selectionEndMs
if (!playheadStillInside)
requestPlayerSeek(selectionStartMs)
因此拖动选区时:
- 如果播放头仍被新选区包含,播放头保持不动;
- 如果播放头被排除到选区外,播放头才回到新选区起点。
3.5 选区内点击定位
时间轴上有一个点击区域:
MouseArea {
id: timelineClickArea
anchors.fill: parent
onPressed: {
var target = editor.positionFromTimelineClick(mouse.x)
if (target < 0) {
mouse.accepted = false
} else {
editor.seekTo(target)
mouse.accepted = true
}
}
}
positionFromTimelineClick() 只接受选区内部时间点。选区外点击会被忽略,避免播放头跳出有效导出范围。
3.6 单一播放按钮
当前只保留一个播放按钮:
ActionButton {
text: (player.playbackState === MediaPlayer.PlayingState || playAfterSeekTimer.running) ? "暂停" : "播放"
enabled: editor.source !== "" && editor.hasSelection
onClicked: editor.togglePlayback()
}
播放规则:
| 场景 | 行为 |
|---|---|
| 正在播放 | 点击按钮暂停 |
| 延迟播放等待中 | 点击按钮取消播放 |
| 播放头在选区内 | 从当前播放头播放 |
| 播放头不在选区内 | 从选区起点播放 |
| 上一次已播到选区终点 | 再次播放从选区起点开始 |
| 播放到选区终点 | 自动停止并把播放头复位到选区起点 |
播放结束逻辑:
function finishPlayback() {
playAfterSeekTimer.stop()
playbackFinished = true
player.stop()
requestPlayerSeek(selectionStartMs)
}
这里使用 stop() 清理播放器状态,降低重复播放或重复导入时旧解码管线残留的概率。
3.7 平滑播放头
QtMultimedia 的 player.position 更新频率较低,直接绑定会让时间轴播放头一顿一顿。编辑器使用显示位置 displayPositionMs 做 UI 插值:
Timer {
id: smoothPlayheadTimer
interval: 16
repeat: true
running: editor.isPlaying
onTriggered: {
var elapsed = Date.now() - editor._lastSyncTime
var interpolated = editor._lastSyncPosition + elapsed
editor.displayPositionMs = editor.clampMs(interpolated)
}
}
真实 seek 和导出仍然使用真实毫秒选区;插值只影响 UI 显示。
3.8 pending seek 防抖
Windows/Qt 5 Multimedia 可能在主动 seek 后先回调旧位置,再回调新位置,导致指针乱跳。编辑器用 pendingSeek 固定 UI 指针:
function requestPlayerSeek(ms) {
pendingSeekMs = clampMs(Number(ms || 0))
pendingSeek = true
setDisplayPosition(pendingSeekMs)
player.seek(pendingSeekMs)
}
在播放器真实位置接近目标后,才解除 pending 状态:
if (Math.abs(player.position - pendingSeekMs) <= 250) {
pendingSeek = false
setDisplayPosition(player.position)
}
3.9 视频画面兜底预览
初始预览由缩略图兜底图承担:
Image {
source: editor.previewImageUrl
visible: source !== ""
&& !editor.userPlaybackRequested
&& player.playbackState !== MediaPlayer.PlayingState
&& !playAfterSeekTimer.running
}
用户开始播放后,兜底图不再显示,避免覆盖 VideoOutput 造成“只有声音、画面不动”的错觉。
此前用于首帧预热的自动静音短播已移除,因为 Qt 5 Multimedia 在 Windows 上偶发会在自动短播、用户播放、重新导入之间产生状态抢占。
3.10 音频峰值轨
AudioPeakTrack 当前只负责显示,不参与选区编辑:
AudioPeakTrack {
enabled: false
peakData: editor.audioPeakData
peakCount: editor.audioPeakCount
selectionStartMs: Math.round(editor.selectionStartMs)
selectionEndMs: Math.round(editor.selectionEndMs)
positionMs: Math.round(editor.displayPositionMs)
}
禁用鼠标交互的原因是:当前需求要求只能通过缩略图时间轴的左右把柄修改选区。
四、异步时间轴缩略图
缩略图由 MediaAnalyzer::generateVideoTrimThumbnails() 生成。
4.1 独立状态
缩略图不使用全局 busy,而使用独立属性:
videoTrimThumbnailBusy videoTrimThumbnailProgress videoTrimThumbnailTotal videoTrimThumbnails
这样缩略图生成期间,页面仍然可以播放、调整选区或执行其它操作。
4.2 取消旧任务和防止旧结果回写
每次刷新时间轴或切换文件都会取消旧任务:
cancelVideoTrimThumbnailJob(); const int jobId = ++m_videoTrimThumbnailJobId; QSharedPointer<QAtomicInt> cancelFlag(new QAtomicInt(0));
旧 worker 可能无法被 QtConcurrent 立即强杀,所以使用两层保护:
| 机制 | 作用 |
|---|---|
cancelFlag | worker 内部尽快停止 |
jobId | 旧任务即使稍后完成,也不会回写 QML 状态 |
4.3 渐进式追加
缩略图生成采用“后台批量抽帧 + 主线程逐张追加”:
extractor.extractTimelineFrames(..., [=](int index, qint64 timestampMs, const QString &imagePath, const QVariantMap &) {
if (cancelFlag->loadAcquire() != 0)
return false;
QVariantMap item;
item.insert("timestampMs", timestampMs);
item.insert("imageUrl", QUrl::fromLocalFile(imagePath).toString());
item.insert("durationMs", durationMs);
QMetaObject::invokeMethod(this, [this, jobId, item]() {
if (jobId != m_videoTrimThumbnailJobId)
return;
appendVideoTrimThumbnail(item);
setVideoTrimThumbnailState(m_videoTrimThumbnailBusy,
m_videoTrimThumbnails.size(),
m_videoTrimThumbnailTotal);
}, Qt::QueuedConnection);
return true;
}, &errorText);
优点:
- UI 不必等全部缩略图完成后才显示;
- 大文件抽帧不会阻塞主线程;
- 进度条可以按已生成数量推进。
4.4 批量抽帧优化
VideoFrameExtractor::extractTimelineFrames() 只打开一次容器、只创建一次解码器,然后对多个时间点循环 seek 和解码:
avformat_open_input(...)
avformat_find_stream_info(...)
avcodec_open2(...)
for (timestamp in timestampsMs) {
av_seek_frame(..., AVSEEK_FLAG_BACKWARD)
avcodec_flush_buffers(codecCtx)
av_read_frame(...)
receiveAndSaveFrame(...)
}
这个实现比“每张图单独打开一次视频”快得多,尤其适合大视频文件。
时间轴缩略图不要求精确到目标帧。实现会 seek 到目标时间之前的关键帧,然后取第一张可解码画面,避免为精确缩略图向后解码很长 GOP。
五、异步音频峰值波形
点击“生成音频波形”时,页面调用:
MediaAnalyzer::generateVideoTrimAudioPeaks(4096)
5.1 为什么不用完整 PCM
完整 PCM 对大视频很重。例如 48kHz、双声道、16-bit 音频:
每秒约 48000 * 2 * 2 = 192KB 1 小时约 691MB
如果把完整 PCM 作为 Q_PROPERTY 传给 QML,会造成明显内存和绑定压力。
5.2 峰值桶格式
AudioDecoder::decodeToPeaks() 把音频压缩成固定数量峰值桶:
bool decodeToPeaks(const QString &filePath,
int peakCount,
QByteArray *peakData,
QVariantMap *peakInfo,
QString *errorText,
const std::function<bool()> &shouldCancel) const;
每个桶保存:
qint16 min qint16 max
4096 桶约 16KB,足够绘制裁剪页上的波形轮廓。
5.3 任务取消
音频峰值任务也使用 jobId 和 cancelFlag:
cancelVideoTrimAudioPeakJob(); const int jobId = ++m_videoTrimAudioPeakJobId; QSharedPointer<QAtomicInt> cancelFlag(new QAtomicInt(0));
切换文件或重复导入时,旧解码任务会尽快退出;即使稍后返回,也不会覆盖新文件的峰值数据。
六、导出调度:MediaAnalyzer::trimCurrentVideoCopy()
QML 导出入口:
Q_INVOKABLE bool trimCurrentVideoCopy(qint64 startMs,
qint64 endMs,
const QString &filePath,
const QString &format,
bool keepAudio,
bool keepSubtitle,
bool copyMetadata);
它负责:
- 校验当前文件和选区;
- 规范化输出路径;
- 自动补全输出后缀;
- 设置全局
busy; - 在后台线程调用
VideoClipProcessor::trimCopy(); - 回主线程更新
videoTrimInfo和status。
路径处理支持 file:// URL 和普通本地路径:
if (outputPath.startsWith("file:", Qt::CaseInsensitive))
outputPath = QUrl(outputPath).toLocalFile();
if (outputPath.isEmpty())
outputPath = defaultVideoTrimPath(format);
七、FFmpeg Stream Copy 裁剪
VideoClipProcessor::trimCopy() 是实际导出实现。
7.1 当前能力边界
当前只实现快速裁剪:
- 不解码视频帧;
- 不重新编码音频或视频;
- 复制 packet;
- 起点按关键帧附近对齐;
- 输出 packet 时间戳从 0 附近重新开始。
7.2 流保留策略
当前保留策略:
| 流类型 | 是否保留 |
|---|---|
| 视频 | 始终保留 |
| 音频 | 由 keepAudio 决定 |
| 字幕 | 由 keepSubtitle 决定 |
| 附件/data/未知流 | 暂不复制 |
7.3 关键帧 seek
stream copy 不能从任意 P/B 帧开始复制。导出前会 seek 到目标起点之前的关键帧:
av_seek_frame(inCtx, videoStreamIndex, seekTarget, AVSEEK_FLAG_BACKWARD);
因此实际写入起点可能早于用户选择起点。结果面板会展示请求起点和实际关键帧起点,帮助用户理解差异。
7.4 packet 写入和时间戳重写
导出循环读取输入 packet,跳过未映射流,把保留流写入输出容器:
av_packet_rescale_ts(packet, inStream->time_base, outStream->time_base); av_interleaved_write_frame(outCtx, packet);
写入前会平移 pts/dts,让新文件从 0 附近开始播放,并尽量保持音视频字幕之间的相对同步。
7.5 容器兼容性
由于不重编码,源编码必须被目标容器接受。常见建议:
- MP4 适合 H.264/AAC 等常见组合;
- MKV 更宽容,适合作为失败时的备选;
- WebM 对编码组合要求更严格;
- 字幕流失败时可以取消“保留字幕”再导出。
八、稳定性处理
8.1 重复导入相同文件
问题:同一路径重复导入时,currentFileChanged 和 MediaPlayer.sourceChanged 都可能不触发。
解决:
MediaAnalyzer::openFile()每次成功都emit fileOpened();VideoTrimPage.qml监听fileOpened();VideoTrimEditor.resetForImportedFile()主动清播放器、播放头、pending seek 和选区;- 页面重新触发缩略图生成。
8.2 防止旧异步任务污染新文件
缩略图和音频峰值任务都使用:
jobId + QAtomicInt cancelFlag
切换文件时旧任务可以继续在后台自然退出,但回主线程时会因 jobId 不匹配而被丢弃。
8.3 避免 UI 卡顿
耗时操作均放到后台:
| 操作 | 线程策略 |
|---|---|
| 时间轴缩略图 | QtConcurrent 后台执行,逐张回主线程追加 |
| 音频峰值 | QtConcurrent 后台流式解码,完成后一次回传小数据 |
| 视频导出 | QtConcurrent 后台 stream copy |
8.4 避免“只有声音,画面不动”
当前采取了几项处理:
- 播放时隐藏兜底
Image,避免覆盖VideoOutput; - 去掉自动静音短播预热,减少 Qt Multimedia 状态抢占;
- 播放启动前用
pendingSeek固定目标位置; - 如果播放器真实位置已接近播放头,不重复 seek,减少后端 seek/play 竞态;
- 播放结束和重复导入时使用
stop()清理播放器状态。
九、当前限制
| 限制 | 说明 |
|---|---|
| 非精确裁剪 | stream copy 起点按关键帧附近对齐 |
| 不支持空白拖选 | 当前交互只允许左右把柄调整选区 |
| 音频轨只显示 | AudioPeakTrack 在裁剪页禁用鼠标交互 |
| 无磁盘缓存 | 缩略图和峰值只服务当前会话,暂不做持久缓存 |
| 无导出进度 | 导出只显示 busy/status,未暴露 packet 级进度 |
十、后续扩展方向
| 能力 | 推荐方向 |
|---|---|
| 精确裁剪 | 新增 trimReencode(),走解码、裁剪、编码、mux 管线 |
| 关键帧可视化 | 抽帧或探测阶段额外提取关键帧时间点 |
| 缩略图磁盘缓存 | 以文件路径、mtime、大小、时长、参数生成 cache key |
| 导出进度和取消 | 给 VideoClipProcessor 增加进度回调和取消标志 |
| 多片段导出 | 复用 trimCopy(),外层批量调度多个选区 |
| 精细音频定位 | 允许音频轨点击定位,但仍不允许其改变选区 |
十一、小结
当前视频片段裁剪实现的核心特点:
- 页面打开后默认选中整段视频;
- 只能通过左右把柄调整选区;
- 点击选区内只定位播放头;
- 单一播放按钮控制播放/暂停;
- 播放头使用 16ms 插值,移动更平滑;
- 缩略图异步批量抽帧、逐张显示;
- 音频波形使用轻量 min/max 峰值桶;
- 导出使用 FFmpeg stream copy,速度快、画质无损;
- 通过
fileOpened()、jobId、cancelFlag、pendingSeek 和播放器 stop 处理重复导入、旧任务回写和播放状态竞态。
这套实现的定位是稳定、高效的快速裁剪。它明确承认关键帧对齐限制,并把精确裁剪留给后续单独的重编码管线实现。
以上就是基于Qt + FFmpeg实现视频片段裁剪功能的详细内容,更多关于Qt FFmpeg视频片段裁剪的资料请关注脚本之家其它相关文章!
