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 协议。

相关推荐
狗凯之家源码网7 分钟前
2026 版短视频与视频号去水印源码深度评测
音视频
深圳市青牛科技实业有限公司2 小时前
GC1843|24bit 立体声差分输入 ADC,超小封装音频采集芯片
音视频
W_chuanqi3 小时前
使用EV录屏视频噪声很大
音视频·ev录屏
开开心心就好3 小时前
超市定时播音软件,免费版支持循环播放
智能手机·ffmpeg·ocr·word·vim·音视频·visual studio
m0_738185824 小时前
Flutter 鸿蒙化实战:flutter_iot_wifi 适配 OpenHarmony,IoT 设备 WiFi 配网
物联网·flutter·华为·harmonyos·鸿蒙
企业数字化笔记5 小时前
视频成片怎么稳定导出?编码选择、GPU/CPU降级与媒体交付验收
java·python·ffmpeg
企业数字化笔记6 小时前
视觉智能体怎么选技术?目标检测、跟踪、分割、OpenCV和FFmpeg的职责划分
opencv·目标检测·ffmpeg
linux_cfan6 小时前
videojs v10 源代码系列解读:35 · HLS 解析器:multivariant 与 media playlist
前端·javascript·音视频
硬件学长森哥16 小时前
成像技术实例-HDR视频技术基础
图像处理·音视频
美狐美颜SDK开放平台20 小时前
Android与iOS直播APP美颜有什么区别?视频美颜SDK开发详解
android·ios·音视频·视频美颜sdk