实现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。


三、三种剪切模式的设计

14

三种模式共享同一个Timeline控件,区别在于确定入点/出点的方式不同。

3.1 自由剪辑(Ctrl+T)

16

这是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)

18

"边播边标"模式。用一个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)

19

"批量输入"模式。用一个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剩下的合并功能(视频拼接、音频混音、音视频混流),以及对推流管线的进一步优化。


转载需注明出处和原文链接。

posted @ 2026-05-29 20:40  rambos1996  阅读(13)  评论(0)    收藏  举报