flutter + ffmpeg_kit_extended_flutter 大视频合并下载

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:):

    yaml 复制代码
    ffmpeg_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):

  1. 引入(main.dart):

    dart 复制代码
    import 'package:ffmpeg_kit_extended_flutter/ffmpeg_kit_extended_flutter.dart';
  2. 启动阶段初始化(main.dart):

    dart 复制代码
    await _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 用于预扫描/续传时判断已有分段是否有效,校验规则:

  1. 文件大小必须为 188 字节整数倍(TS 协议每个 packet 固定 188 字节)。
  2. 首字节必须是同步字节 0x47
  3. 最后一个完整 packet 起始字节必须是 0x47。
  4. 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.dllsqlite3.dll 等)复制到可执行文件同目录:

    cmake 复制代码
    set(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_flutter 0.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 协议。

相关推荐
tedcloud1239 小时前
Wand-Enhancer 怎么搭建?开源 Wand 客户端增强与远程控制工具介绍
大数据·服务器·人工智能·开源·音视频
找方案12 小时前
AI视频生成对决:Sora vs 可灵 vs Veo,谁能定义未来
人工智能·机器学习·音视频
m0_6145235514 小时前
差异很大的两段视频怎么自然衔接:切点、AI转场与失败回退
人工智能·音视频
TechVoyager_824615 小时前
IT68353多协议视频转换芯片的硬件架构与协议能力分析
硬件架构·音视频·it68353·多协议音视频·typec 扩展坞·dp hdmi 转换
程序员老刘16 小时前
Dart Skills CLI 1.0发布,老刘年初的预言兑现了
flutter·ai编程·dart
可乐鸡翅yeah_18 小时前
hls.js 播放质量埋点实战,采集卡顿、起播、错误指标定位线上用户问题
开发语言·前端·javascript·ecmascript·音视频·m3u8·音视频在线播放
youandyouyou19 小时前
医学远程示教同屏直播的技术实现路径:以连通宝双流方案为例
音视频·网络通信
A133455519 小时前
保姆级教程:视频播放器怎么导入SRT外挂字幕
音视频
揽秀亭长19 小时前
如何把提取一个视频的脚本?实测3种提取视频脚本的方法
音视频