跳到主要内容

FFmpeg 剪辑管线

BlinkLife 使用 FFmpegKit 进行视频剪辑和合集生成,所有 FFmpeg 调用必须使用 executeAsync(异步),通过 Completer 桥接为 Future。

剪辑流程概览

DotRecord[] + 剪辑参数 (beforeSeconds, afterSeconds)

├── _calculateClipSegments() → ClipSegment[]
│ 为每个打点计算 [dotTime - before, dotTime + after]

├── _mergeOverlappingSegments() → 合并重叠区间
│ 相邻片段间隔 < 1s → 合并

├── 逐段执行 _clipSegment()
│ ├── FFmpeg 命令 → executeAsync + Completer
│ ├── 检查 ReturnCode.isSuccess
│ └── 生成缩略图

└── 批量插入 DB (clip_records)

FFmpeg 命令模板

单片段剪辑(-c copy,无转码)

ffmpeg -ss {startTime} -i "{inputPath}" -t {duration} -c copy -y "{outputPath}"

:::danger 关键约束

  • -ss 必须放在 -i 前面(input seeking),否则慢 10-100 倍
  • 禁止使用 FFmpegKit.execute(同步),会阻塞 UI 线程导致 ANR :::

合集生成(concat demuxer)

# 生成 concat 列表文件
echo "file '/path/to/clip1.mp4'" > concat_list.txt
echo "file '/path/to/clip2.mp4'" >> concat_list.txt

# 合并
ffmpeg -f concat -safe 0 -i concat_list.txt -c copy -y "{outputPath}"
  • 使用 concat demuxer(-f concat),不用 concat protocol
  • -safe 0 允许绝对路径
  • 临时列表文件在成功/失败/异常路径均清理

片段计算逻辑

ClipSegment = {
startTime: max(0, dotTime - beforeSeconds),
endTime: min(videoDuration, dotTime + afterSeconds),
actionType: dot.action,
actionCount: 1, // 合并后可能 > 1
}

重叠合并

片段A: [5s, 15s] 片段B: [14s, 24s]
间隔 < 1s → 合并为: [5s, 24s], actionCount = 2

executeAsync 桥接

final completer = Completer<ReturnCode?>();
await FFmpegKit.executeAsync(command, (session) async {
final returnCode = await session.getReturnCode();
completer.complete(returnCode);
});
final rc = await completer.future;
if (!ReturnCode.isSuccess(rc)) {
// 记录完整命令到日志
}

输出文件命名

clip_{actionType}_{index}_{timestamp}.mp4 // 剪辑片段
merged_{timestamp}.mp4 // 合集视频

带时间戳避免与旧批次文件冲突(MediaScanner 锁定旧文件导致 FFmpeg -y 覆盖失败)。

风险点

风险影响缓解
-ss 放在 -i 后面剪辑极慢编码约束写入 CLAUDE.md
使用 execute 同步UI 线程阻塞 ANR禁止使用
concat 列表文件未清理磁盘泄露try-finally 清理
FFmpeg 返回非 0输出损坏ReturnCode.isSuccess 检查

相关文档