实现FFmpeg播放器的剪辑功能——三种剪切模式与六个典型Bug修复
一、前言
几个月前开始了一个"从零构建FFmpeg全链路播放器"的项目,技术栈选型是C++17 + Qt 5.14.2 + FFmpeg 4.x,编译器用MSVC 2017。目标是覆盖FFmpeg的三条核心链路:解码播放、编码推流、重编码剪辑。
项目地址:https://github.com/johnjiamzhong-project/RambosPlayer
最近完成了Phase 12的核心内容——三种剪切模式(自由剪辑、浏览剪切、多段剪切)的实现与互斥逻辑。本文不讲播放器本身,聚焦剪辑功能的架构设计和实现中遇到的几个典型Bug。
二、项目背景
播放器的核心架构如下:
UI 层 (MainWindow)
│
▼
PlayerController
│
▼
DemuxThread ────────────────────────┐
│ videoPacketQueue audioPacketQueue │
▼ ▼
VideoDecodeThread AudioDecodeThread
│ videoFrameQueue │ QAudioOutput
▼ │ 音频时钟
VideoRenderer ◄──── AVSync ◄────┘
剪辑导出是另一条独立的管线——ExportWorker在QThread子线程中运行:
源文件 → av_read_frame → avcodec_send_packet → avcodec_receive_frame
→ sws_scale(像素格式转换)→ avcodec_send_frame → avcodec_receive_packet
→ av_write_frame → 输出MP4
编码器配置为libx264 superfast CRF 17 + AAC 192kbps。
三、三种剪切模式的设计

三种模式共享同一个Timeline控件,区别在于确定入点/出点的方式不同。
3.1 自由剪辑(Ctrl+T)

这是Phase 10已完成的基础功能。Timeline是一个自定义QWidget,内部绘制了时间刻度尺、缩略图轨道、入点/出点把手。
// 把手拖拽的核心逻辑:ptsToX / xToPts 时间↔像素换算
int Timeline::ptsToX(int64_t pts) const {
int64_t range = trackRight_ - trackLeft_;
return trackLeft_ + static_cast<int>(pts * range / duration_);
}
// mouseMoveEvent 中实时更新把手位置
void Timeline::mouseMoveEvent(QMouseEvent* ev) {
int64_t newPts = xToPts(ev->x());
if (handleDrag_ == 0)
inPts_ = qBound<int64_t>(0, newPts, outPts_ - kMinTrimUs);
else
outPts_ = qBound<int64_t>(inPts_ + kMinTrimUs, newPts, duration_);
emit trimPointChanged(inPts_, outPts_);
}
把手点击区域覆盖整条竖线高度,方便鼠标抓取。拖拽时实时发射信号,状态栏同步显示区间时长。
3.2 浏览剪切(Ctrl+B)

"边播边标"模式。用一个BrowseClipper控制器类管理状态:
class BrowseClipper : public QObject {
Q_OBJECT
public:
void start(); // 进入模式,自动播放
void stop(bool showDialog = true); // 退出,弹确认对话框
void markPoint(); // 空格键:入点↔出点切换
private:
enum State { Idle, Marking };
int64_t inPts_; // 当前入点
QList<QPair<int64_t, int64_t>> markedSegments_; // 浏览标记的区间
};
交互流程:
1. 开启 → 视频自动播放,状态栏提示"按空格标记入点"
2. 第一次空格 → 录制当前播放位置为入点,底部导轨显示绿色竖线
3. 第二次空格 → 录制为出点,生成区间块;如果出点早于入点则重置
4. 重复步骤2-3标记多段
5. 退出 → 弹出确认对话框,列出所有区间,用户勾选保留
浏览剪切与常规播放暂停的冲突处理:浏览模式激活时,空格键走markPoint()逻辑,不走play/pause。
3.3 多段剪切(Ctrl+M)

"批量输入"模式。用一个QDialog实现:
// 时间解析核心逻辑
bool SegmentClipper::parseLine(const QString& line, int64_t& startUs,
int64_t& endUs, QString& errorMsg) const {
// 支持 MM:SS 和 HH:MM:SS 两种格式
// 分隔符支持 - → 中英文逗号
QStringList parts = line.trimmed().split(QRegularExpression("[\\-→,,]"));
// 解析两个时间 → 验证 → 检查重叠
}
三条验证规则:不超过视频总时长、起始早于结束、区间之间不重叠。验证通过后"确定"按钮才可用。
3.4 底部导轨架构
三种模式共享的"多区间容器":
时间刻度尺
缩略图轨道 + 入/出点把手
底部导轨(多区间色块 + 时间标签)
██████████ ██████████████
00:10-00:25 01:30-02:00
Timeline新增的数据结构:
QList<QPair<int64_t, int64_t>> segments_; // 所有区间(按起始时间排序)
int64_t pendingInPts_ = -1; // 浏览剪切的待定入点
bool bottomBarVisible_ = false; // 底部导轨可见性
bool handlesVisible_ = true; // 自由剪辑把手可见性
四、模式互斥逻辑
三种模式不能同时激活,切换规则如下:
| 进入 \ 已有状态 | 自由剪辑 Ctrl+T | 浏览剪切 Ctrl+B | 多段剪切 Ctrl+M |
|---|---|---|---|
| 自由剪辑 Ctrl+T | — | 静默退出,保留区间 | 静默退出,保留区间 |
| 浏览剪切 Ctrl+B | 询问保存 → 退出 | — | 静默退出,保留区间 |
| 多段剪切 Ctrl+M | 询问保存 → 退出 | 静默退出,保留区间 | — |
| 多段剪切取消 | 恢复自由剪辑 | 恢复浏览剪切 | — |
核心原则:区间不因模式切换而丢失。底部导轨是所有模式共享的容器,谁添加的区间都共存,不会因为换模式被清掉。
这个互斥逻辑在onSegmentClipTriggered中实现:
void MainWindow::onSegmentClipTriggered() {
// 1. 记录进入前的模式状态
bool prevBrowse = ui->actionBrowseClip->isChecked();
bool prevFree = ui->actionTrimMode->isChecked();
// 2. 静默退出浏览剪切
if (prevBrowse) {
browseClipper_->stop(false); // false = 不弹对话框
ui->actionBrowseClip->setChecked(false);
}
// 3. 退出自由剪辑(询问是否保存把手区间)
if (prevFree) { /* 弹QMessageBox,询问保存 */ }
// 4. 弹出多段剪切对话框
SegmentClipper dlg(timeline_, duration_ * 1000, this);
if (dlg.exec() == QDialog::Accepted) {
// 确认:底部导轨保持,无模式激活
} else {
// 取消:恢复之前的模式
if (prevBrowse) { /* 恢复浏览 */ }
else if (prevFree) { /* 恢复自由 */ }
}
}
五、Bug修复记录
Bug #1:合并区间后删除失败
现象:浏览标记0-10和5-15(重叠),选"合并"后退出对话框取消勾选,删除失败,区间残留。
根因:mergeSegment把区间扩展为0-15,但markedSegments_记录的是原始边界(0,10)。removeSegment用精确匹配删除,找不到。
修复:
- 合并后不再追加到markedSegments_(合并段始终保留,不出现在删除列表)
- 退出对话框改用removeSegmentAt(index)按索引删除
// 修复前:精确匹配,边界漂移后失效
bool Timeline::removeSegment(int64_t startUs, int64_t endUs) {
auto it = std::find_if(segments_.begin(), segments_.end(),
[&](const auto& seg) { return seg.first == startUs && seg.second == endUs; });
}
// 修复后:按索引删除,不受边界影响
bool Timeline::removeSegmentAt(int index) {
if (index < 0 || index >= segments_.size()) return false;
segments_.erase(segments_.begin() + index);
}
Bug #2:相邻区间被误合并
现象:标记0-10和10-20(恰好相接),被合并为0-20。
根因:mergeAdjacent的条件是segments[i].first <= last.second,≤把相邻也合并了。
修复:改成<,只有真正重叠才合并。
// 修复前
if (segments_[i].first <= last.second) // 重叠或相邻,合并
// 修复后
if (segments_[i].first < last.second) // 仅重叠才合并
Bug #3:退出浏览剪切后dock/导轨状态混乱
这个bug在两个方向上反复出现:
场景A:浏览标记2段 → 退出保留 → dock被隐藏,导轨看不见。
场景B:Ctrl+T进自由剪辑 → 底部导轨被无条件隐藏,浏览的区间在但不可见。
根因:混淆了"dock可见性"和"底部导轨可见性"两个概念。onTrimModeToggled里先隐藏导轨再退浏览,顺序错误。
修复:
- dock可见性 ← "是否有剪辑模式激活"
- 底部导轨可见性 ← "是否有区间"
- 进入自由剪辑前先记录hadBrowseSegments,有则保留导轨
bool hadBrowseSegments = false;
if (ui->actionBrowseClip->isChecked()) {
if (!timeline_->segments().isEmpty())
hadBrowseSegments = true;
browseClipper_->stop(false);
}
timeline_->setBottomBarVisible(hadBrowseSegments);
Bug #4:多段剪切确认清空已有区间
现象:浏览标记2段 → Ctrl+M输入1段 → 确认 → 底部导轨只剩1段。
根因:SegmentClipper::onAccept先调clearSegments()再追加。
修复:删掉clearSegments(),只追加不清除。
Bug #5:多段剪切取消后模式丢失
现象:浏览标记2段 → Ctrl+M取消 → 浏览模式不恢复,菜单不勾选。
根因:取消路径有两条分支——segments为空才恢复模式,非空时只隐藏把手。
修复:合并为一个else,统一按prevBrowse/prevFree恢复。
Bug #6:未打开文件无反馈
现象:启动程序直接Ctrl+B → 菜单勾选被反选,无任何提示。
根因:浏览剪切用状态栏提示(3秒消失),自由剪辑甚至没检查。
修复:三个入口统一弹QMessageBox。
六、文件变更统计
| 文件 | 行数 | 说明 |
|---|---|---|
src/timeline.h/.cpp |
~440 | 底部导轨、区间管理、把手显隐 |
src/browseclipper.h/.cpp |
~190 | 浏览剪切控制器 |
src/segmentclipper.h/.cpp |
~280 | 多段剪切对话框 |
src/mainwindow.h/.cpp |
~760 | 菜单入口、模式切换 |
src/timeutil.h |
新增 | usToLabel 公共函数 |
七、总结
Phase 12开发下来,感触最深的是状态管理的重要性。三个模式的互斥、底部导轨与dock的可见性、区间边界的精确匹配——这些看似简单的交互逻辑,在实现过程中反复踩坑。大部分Bug的根因都可以归结为两种:执行顺序错误(先隐藏
再退模式),或者概念混淆(把两层状态当一层管)。
项目已开源:https://github.com/johnjiamzhong-project/RambosPlayer
下一步计划是Phase 12剩下的合并功能(视频拼接、音频混音、音视频混流),以及对推流管线的进一步优化。
转载需注明出处和原文链接。

浙公网安备 33010602011771号