ffmpeg 完成大文件合并下载
-
- [1. 需求](#1. 需求)
- [2. 依赖与配置](#2. 依赖与配置)
-
- [2.1 依赖声明(pubspec.yaml)](#2.1 依赖声明(pubspec.yaml))
- [2.2 关键点:为什么用 `extended` 版](#2.2 关键点:为什么用
extended版)
- [3. 初始化流程](#3. 初始化流程)
- [4. 核心调用流程](#4. 核心调用流程)
-
- [4.1 完整下载→合并流程](#4.1 完整下载→合并流程)
- [4.2 合并入口(FFmpeg 优先,失败回退)](#4.2 合并入口(FFmpeg 优先,失败回退))
- [4.3 FFmpeg 队列调度](#4.3 FFmpeg 队列调度)
- [4.4 实际 FFmpeg 执行(生成 ffconcat + executeAsync)](#4.4 实际 FFmpeg 执行(生成 ffconcat + executeAsync))
- [5. FFmpeg 命令解析](#5. FFmpeg 命令解析)
-
- [ffconcat 列表文件要点](#ffconcat 列表文件要点)
- [6. 技术要点汇总](#6. 技术要点汇总)
-
- [6.1 全局串行队列防原生层崩溃](#6.1 全局串行队列防原生层崩溃)
- [6.2 超时保护](#6.2 超时保护)
- [6.3 失败回退二进制拼接](#6.3 失败回退二进制拼接)
- [6.4 TS 分段完整性校验](#6.4 TS 分段完整性校验)
- [6.5 异常分类与磁盘错误识别](#6.5 异常分类与磁盘错误识别)
- [7. 平台打包要点](#7. 平台打包要点)
-
- [7.1 Windows](#7.1 Windows)
- [7.2 macOS](#7.2 macOS)
- [7.3 体积/协议说明](#7.3 体积/协议说明)
1. 需求
依赖:用 ffmpeg_kit_extended_flutter(0.4.4,社区 fork,官方 ffmpeg_kit_flutter 已停更)
FFmpeg 承担**「M3U8 → MP4 客户端合成」**的合并(重封装)环节
- 用户点击下载 M3U8 视频 → 客户端下载并解析 M3U8 播放列表 → 逐个下载 TS 分段 → 用 FFmpeg 将 TS 分段合并为单个 MP4 文件。
- FFmpeg 只做
-c copy流复制重封装(不重新编码),速度快、质量无损。 - 合并失败时自动回退为「二进制直接拼接」,保证功能可用性。
2. 依赖与配置
2.1 依赖声明(pubspec.yaml)
-
依赖包(pubspec.yaml):
yaml# 视频处理 ffmpeg_kit_extended_flutter: ^0.4.4 -
锁定版本(pubspec.lock):
ffmpeg_kit_extended_flutter: 0.4.4 -
打包配置(pubspec.yaml:):
yamlffmpeg_kit_extended_config: type: "base" gpl: false small: true windows: "bundle-base-windows-x86_64-shared-small-lgpl.zip" macos: "bundle-base-macos-universal-debug-lgpl.xcframework.zip"配置含义:
type: "base"------ 使用 base 变体(仅基础编解码能力,不含 gpl 模块)。gpl: false------ 不含 GPL 协议组件,规避 GPL 传染(LGPL 友好)。small: true------ 小型化裁剪,减小体积。windows/macos------ 指定各平台预编译二进制包的下载地址。
2.2 关键点:为什么用 extended 版
官方 ffmpeg_kit_flutter 已停止维护(作者于 2024 年归档)。本项目改用社区维护的 ffmpeg_kit_extended_flutter ,它通过 Flutter 的 native assets / hooks 机制 在构建期下载并缓存预编译的 FFmpeg 二进制(见 .dart_tool/hooks_runner/ffmpeg_kit_extended_flutter/...),并自动参与各平台打包。
3. 初始化流程
入口在 (lib/main.dart):
-
引入(main.dart):
dartimport 'package:ffmpeg_kit_extended_flutter/ffmpeg_kit_extended_flutter.dart'; -
启动阶段初始化(main.dart):
dartawait _writeBootStage('ffmpeg_init'); await FFmpegKitExtended.initialize();技术要点:
- 初始化放在
WidgetsFlutterBinding.ensureInitialized()之后、Sentry / 业务服务之前。 _writeBootStage('ffmpeg_init')在调用前把当前阶段写入本地boot_stage.txt文件,用于崩溃后定位启动进度。- 初始化失败会被最外层
main()的try/catch捕获,走兜底崩溃写入 + 降级错误页。
- 初始化放在
4. 核心调用流程
FFmpeg 全部调用集中在 (lib/app/core/services/m3u8_downloader.dart)。
4.1 完整下载→合并流程
DownloadManager._executeM3u8Download()
└─ M3u8Downloader.downloadAndMerge(task) # 完整流程
├─ Step 1: _fetchPlaylistWithRetry() # 下载/解析 M3U8(含 Master 递归 + 403 续期)
├─ Step 2: 逐个下载 TS 分段(Range 断点续传 + 403 续期 + 完整性校验)
├─ Step 3: _mergeTsToMp4() # ★ FFmpeg 合并入口
│ ├─ 优先 _ffmpegMerge() # FFmpeg 重封装
│ └─ 失败回退 _binaryMerge() # 二进制拼接
└─ Step 4: 清理临时目录
4.2 合并入口(FFmpeg 优先,失败回退)
m3u8_downloader.dart:
dart
Future<void> _mergeTsToMp4(Directory tsDir, List<double> durations, String outputPath) async {
final totalSegments = durations.length;
// 优先使用 ffmpeg 重封装(通过串行队列防止并发导致原生层崩溃)
try {
await _ffmpegMerge(tsDir, durations, outputPath);
return;
} catch (e) {
_log.warn('M3U8', 'ffmpeg 重封装失败: ${e.toString().split('\n').first},回退二进制拼接');
}
// 回退:二进制拼接
await _binaryMerge(tsDir, totalSegments, outputPath);
}
4.3 FFmpeg 队列调度
m3u8_downloader.dart:
dart
Future<void> _ffmpegMerge(Directory tsDir, List<double> durations, String outputPath) async {
_log.info('M3U8', '等待 ffmpeg 队列...');
final result = await _ffmpegQueue.enqueue(() => _doFfmpegMerge(tsDir, durations, outputPath));
_log.info('M3U8', 'ffmpeg 队列任务完成');
return result;
}
4.4 实际 FFmpeg 执行(生成 ffconcat + executeAsync)
m3u8_downloader.dart:
dart
/// 使用 ffmpeg 的 concat 协议合并 TS 分片为单个文件。
///
/// [tsDir] 存放 TS 分片的目录。
/// [durations] 每个分片对应的时长列表(通常来自 M3U8 的 #EXTINF 值)。
/// [outputPath] 合并后输出文件的完整路径。
Future<void> _doFfmpegMerge(Directory tsDir, List<double> durations, String outputPath) async {
final totalSegments = durations.length;
// 生成 ffconcat 列表(含显式 duration 指令,用 M3U8 #EXTINF 值精确定义每段时长)
// 这个列表文件会告诉 ffmpeg 按什么顺序、以什么时长拼接哪些文件
final listFile = File('${tsDir.path}/concat_list.txt');
final buf = StringBuffer();
buf.writeln('ffconcat version 1.0');
// 遍历每个分片,逐行写入文件路径和时长
for (int i = 0; i < totalSegments; i++) {
// 分片命名规则:segment_00000.ts、segment_00001.ts ... 补齐 5 位数字
final tsFile = File('${tsDir.path}/segment_${i.toString().padLeft(5, '0')}.ts');
// 如果某个分片缺失,直接抛出异常,避免 ffmpeg 报出难以定位的错误
if (!await tsFile.exists()) {
throw M3u8Exception('合并失败: 缺少 segment_$i.ts');
}
// 写入 file 指令。
// ffconcat 中路径若包含单引号,需要用 '\'' 进行转义,
// 所以这里把路径里的 ' 替换成 '\'',保证路径被正确解析。
buf.writeln("file '${tsFile.path.replaceAll("'", r"'\''")}'");
// 显式指定每段时长,能让 ffmpeg 更精确地生成时间戳,
// 尤其当 TS 分片本身的 PTS/DTS 不连续时,可以避免音画不同步。
// 注意:duration 必须写在 file 之后,否则会被忽略。
if (i < durations.length && durations[i] > 0) {
buf.writeln('duration ${durations[i]}');
}
}
// 将生成的列表内容写入 concat_list.txt 文
await listFile.writeAsString(buf.toString());
try {
// 执行 ffmpeg 命令:
final session = await FFmpegKit.executeAsync(
'-y -f concat -safe 0 -i "${listFile.path}" -c copy'
' -avoid_negative_ts make_zero "$outputPath"',
).timeout(
// 设置最长执行时间,防止 ffmpeg 卡死导致整个任务阻塞
const Duration(minutes: 60),
onTimeout: () => throw M3u8Exception('ffmpeg 合并超时(>60分钟)'),
);
// 获取 ffmpeg 的返回码,用于判断执行是否成功
final returnCode = session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
_log.info('M3U8', 'ffmpeg 重封装完成: $outputPath');
} else {
final failStackTrace = session.getFailStackTrace();
throw M3u8Exception('ffmpeg 返回非零: $failStackTrace');
}
} finally {
// 无论成功还是失败,都清理掉临时生成的 concat 列表文件,避免残留在磁盘上
await listFile.delete();
}
}
5. FFmpeg 命令解析
实际执行的命令为:
bash
ffmpeg -y -f concat -safe 0 -i "<concat_list.txt>" -c copy -avoid_negative_ts make_zero "<output.mp4>"
| 参数 | 含义 | 技术要点 |
|---|---|---|
-y |
覆盖已存在的输出文件 | 避免重复执行时交互式确认卡住 |
-f concat |
使用 concat demuxer | 按列表顺序读取多个 TS 文件,处理容器连续性 |
-safe 0 |
允许列表中出现绝对路径 | 默认安全模式只允许相对路径,这里 TS 是绝对路径 |
-i <list> |
输入为 ffconcat 列表文件 | 而非直接传 TS 文件 |
-c copy |
流复制(stream copy),不重新编码 | 关键:快、无损,仅做重封装 |
-avoid_negative_ts make_zero |
修复负时间戳 | 避免合并后 PTS/DTS 出现负值导致播放异常 |
ffconcat 列表文件要点
- 首行固定
ffconcat version 1.0。 - 每个分段一行
file '<绝对路径>'。 - 紧跟一行
duration <秒>,值取自 M3U8 播放列表的#EXTINF时长,精确控制每段时长,避免依赖 TS 内部时间戳导致时长漂移。 - 路径中的单引号做转义:
.replaceAll("'", r"'\''")。 - 列表文件用完即删(
finally { await listFile.delete(); })。
6. 技术要点汇总
6.1 全局串行队列防原生层崩溃
m3u8_downloader.dart:
dart
/// 全局 ffmpeg 合并队列 --- 防止多个 M3U8 同时执行 ffmpeg 导致原生层崩溃
static final _ffmpegQueue = _FfmpegQueue();
class _FfmpegQueue {
Future _last = Future.value();
Future<T> enqueue<T>(Future<T> Function() task) {
final chained = _last.then((_) => task());
_last = chained.whenComplete(() {});
return chained;
}
}
关键点 :虽然 DownloadManager 的并发数 maxConcurrent = 3,但 FFmpeg 是重量级原生进程,多路同时执行可能触发原生层 SIGSEGV / OOM 。因此单独用一条 Future 链把 FFmpeg 合并任务串行化------所有 M3U8 任务共享同一个队列,一个合并完成后再执行下一个。
6.2 超时保护
m3u8_downloader.dart:executeAsync(...).timeout(60分钟),超时抛 M3u8Exception('ffmpeg 合并超时(>60分钟)'),随后被上层捕获并回退二进制拼接。
6.3 失败回退二进制拼接
m3u8_downloader.dart:_binaryMerge 按顺序把每个 TS 文件流式 写入输出文件(openRead() + raf.writeFrom(chunk)),避免大文件一次性载入内存。TS 是 MPEG-TS 格式,简单拼接在多数场景下即可被播放器识别,作为 FFmpeg 失败时的兜底。
6.4 TS 分段完整性校验
m3u8_downloader.dart:_isValidTsSegment 用于预扫描/续传时判断已有分段是否有效,校验规则:
- 文件大小必须为 188 字节整数倍(TS 协议每个 packet 固定 188 字节)。
- 首字节必须是同步字节 0x47。
- 最后一个完整 packet 起始字节必须是 0x47。
- 25% / 50% / 75% 位置按 188 字节对齐后采样同步字节 0x47。
该校验与 FFmpeg 本身无关,但直接影响「分段是否完整、是否触发重下/合并」的可靠性。
6.5 异常分类与磁盘错误识别
DownloadManager 对 M3U8 合并/下载失败做精细分类(download_manager.dart):
isNetworkError→ 「下载中断,等待网络重连!」isDiskFullError→ 「下载失败,磁盘空间已满!」isPathNotFoundError→ 「下载目录不存在...」isCrcError→ 「磁盘数据校验错误...」(Windows ERROR_CRC=23 / 1117)
FFmpeg 合并阶段若抛出 CRC 错误(坏盘),也会被识别并清理不完整文件(_tryDeleteFile)。
7. 平台打包要点
7.1 Windows
-
ffmpeg_kit_extended_flutter通过 native assets hooks 下载的预编译包会缓存到.dart_tool/hooks_runner/shared/ffmpeg_kit_extended_flutter/build/...,内含libffmpegkit.dll。 -
windows/CMakeLists.txt的 post-build 脚本把
native_assets/windows/(含libffmpegkit.dll、sqlite3.dll等)复制到可执行文件同目录:cmakeset(NATIVE_ASSETS_SRC "${PROJECT_BUILD_DIR}/native_assets/windows/") if(EXISTS "${NATIVE_ASSETS_SRC}") file(COPY "${NATIVE_ASSETS_SRC}" DESTINATION "${TARGET_DIR}") endif()
7.2 macOS
- FFmpeg 以 CocoaPods 方式集成(
ffmpeg_kit_extended_flutter0.4.0,见(macos/Podfile.lock))。 - 配置使用
bundle-base-macos-universal-debug-lgpl.xcframework.zip(universal 架构,LGPL)。
7.3 体积/协议说明
small: true+type: base+gpl: false的组合,在满足「TS 重封装为 MP4」需求的前提下尽量减小包体并规避 GPL 协议。