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.dll、sqlite3.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 协议。

相关推荐
hflmwl85 小时前
Wise Data Recovery找回误删的文档照片音乐和视频
音视频·datarecovery·找回误删的文档照片音乐和视频·找回误删的文档照片音乐视频
技灵AI5 小时前
AI 视频转场提示词怎么写?让前后画面衔接更自然
人工智能·prompt·aigc·音视频
youandyouyou7 小时前
跨区培训的屏幕共享怎么做才稳?同屏同步与弱网优化实践
音视频·网络通信
开开心心就好7 小时前
课堂随机点名工具,绿色版免安装双击即用
java·服务器·前端·ffmpeg·音视频·perl·symfony
美狐美颜SDK开放平台8 小时前
直播APP集成美颜SDK,如何梳理第三方组件的数据权限?
开发语言·人工智能·深度学习·音视频·第三方美颜sdk
小鹿软件办公8 小时前
不依赖 FFmpeg 也能录屏?ShareX 22 新增录屏等十余款新工具
ffmpeg·sharex·免费录屏工具
l14372332679 小时前
批量视频硬字幕擦除多少钱?影响报价的关键因素全解析
音视频
喵个咪9 小时前
GoWind ShortPlay 拆解:Flutter 沉浸式 Feed 的播放器工程——生命周期、手势状态机与游客降级
前端·flutter·架构
重生六道9 小时前
B站视频时长统计:一段JS脚本搞定分P/合集总时长(附倍速计算)
开发语言·javascript·音视频
音视频牛哥10 小时前
不换摄像机,也能接入 WebRTC?SmartMediaKit RTSP/RTMP 转 WHIP 技术实践
音视频·webrtc·smartmediakit·rtsp转whip·rtmp转whip·whip推流·rtsp转webrtc播放