视频缩略图按需重生成:从 media_kit 失败到原生 API 成功

December 20, 2025
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

前置问题

20251220-211500-video-thumbnail-upload-time-generation.md 中,我们解决了上传时生成视频缩略图的问题。但遗留了一个需求:

旧视频(上传时未生成缩略图)如何重新生成?

技术挑战

  1. 视频文件是端到端加密存储的,后端无法直接处理
  2. 客户端需要从远程流式 URL 生成缩略图,而非本地文件
  3. 需要支持 Range 请求,避免下载完整视频(可能几百 MB)

第一次尝试:media_kit

方案设计

media_kit 是一个跨平台的视频播放库,理论上可以:

  1. 从 HTTP URL 加载视频(支持 Range 请求)
  2. Seek 到指定位置
  3. 截取当前帧作为缩略图

实现代码

class VideoThumbnailService {
  Future<Uint8List?> generateFromUrl(String streamUrl, Map<String, String> headers) async {
    final player = Player();
    
    try {
      await player.open(Media(streamUrl, httpHeaders: headers));
      
      // 等待视频加载
      await Future.any([
        player.stream.width.firstWhere((w) => w != null),
        Future.delayed(Duration(seconds: 30)),
      ]);
      
      // 截图
      final screenshot = await player.screenshot();
      return screenshot;
    } finally {
      await player.dispose();
    }
  }
}

失败原因

测试时发现严重问题:

[VideoThumbnailService] Result: 0 bytes ← 截图失败!

根本原因分析

media_kit 的 screenshot() 方法依赖渲染管道:

┌──────────────────────────────────────────────────────────────────┐
│ media_kit 架构 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Player ──► VideoController ──► Video Widget ──► GPU 渲染 │
│ │ │ │
│ │ └─────────────────────┐ │
│ ▼ ▼ │
│ screenshot() ◄───────────────────── 渲染缓冲区 │
│ │
└──────────────────────────────────────────────────────────────────┘

问题:

  1. screenshot()GPU 渲染缓冲区 读取画面
  2. 没有 VideoController 和 Video Widget = 没有渲染管道
  3. 后台服务没有 UI = 无法渲染 = 截图永远为空

验证

添加调试日志后发现:

player.stream.width   // 始终为 null
player.stream.height  // 始终为 null
player.state.position // 在增加(说明视频在播放)
player.screenshot()   // 返回 0 bytes

结论:media_kit 需要完整的 UI 渲染管道,无法在后台使用。

第二次尝试:Android 原生 API

方案设计

Android 提供了 MediaMetadataRetriever,可以:

  1. 直接从 HTTP URL 读取视频元数据
  2. 自动发起 Range 请求,只下载必要数据
  3. 提取指定时间点的帧,无需 UI

实现架构

┌──────────────────────────────────────────────────────────────────┐
│ 原生 API 架构 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Flutter Android (Kotlin) │
│ ┌────────────────────┐ ┌────────────────────────────┐ │
│ │ VideoThumbnailSvc │ ──────► │ MainActivity │ │
│ │ │ Channel │ │ │
│ │ generateFromUrl() │ ◄────── │ MediaMetadataRetriever │ │
│ └────────────────────┘ │ .setDataSource(url) │ │
│ │ .getFrameAtTime(5s) │ │
│ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ HTTP Range 请求 │ │
│ │ bytes=0-10485759 │ │
│ │ bytes=1390125- │ │
│ │ bytes=2970856- │ │
│ │ ... │ │
│ └────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘

MainActivity.kt

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        
        // 视频缩略图通道
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "e2eepan/video_thumbnail",
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "generateFromUrl" -> {
                    val url = call.argument<String>("url")
                    val headers = call.argument<Map<String, String>>("headers")
                    
                    Thread {
                        val thumbnail = generateVideoThumbnail(url, headers ?: emptyMap())
                        runOnUiThread {
                            if (thumbnail != null) {
                                result.success(thumbnail)
                            } else {
                                result.error("GENERATION_FAILED", "Failed", null)
                            }
                        }
                    }.start()
                }
                "generateFromFile" -> {
                    val filePath = call.argument<String>("filePath")
                    // 类似处理...
                }
                else -> result.notImplemented()
            }
        }
    }
 
    /**
     * 使用 MediaMetadataRetriever 从 URL 生成视频缩略图
     * 支持 HTTP Range 请求,只下载必要的数据
     */
    private fun generateVideoThumbnail(url: String, headers: Map<String, String>): ByteArray? {
        val retriever = MediaMetadataRetriever()
        try {
            // 设置数据源,支持 HTTP URL 和自定义 headers
            retriever.setDataSource(url, headers)
            
            // 获取第5秒的帧(避免黑屏/片头)
            val bitmap = retriever.getFrameAtTime(
                5_000_000,  // 5秒 = 5,000,000 微秒
                MediaMetadataRetriever.OPTION_CLOSEST_SYNC
            )
            
            if (bitmap != null) {
                val stream = ByteArrayOutputStream()
                bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
                return stream.toByteArray()
            }
            return null
        } finally {
            retriever.release()
        }
    }
}

VideoThumbnailService.dart

class VideoThumbnailService {
  static const _channel = MethodChannel('e2eepan/video_thumbnail');
 
  /// 从本地文件生成(上传时使用)
  Future<Uint8List?> generateFromFile(String filePath) async {
    try {
      return await _channel.invokeMethod<Uint8List>('generateFromFile', {
        'filePath': filePath,
      });
    } on PlatformException {
      return null;
    } on MissingPluginException {
      return null; // 平台不支持(Windows)
    }
  }
 
  /// 从流式 URL 生成(重生成时使用)
  Future<Uint8List?> generateFromUrl(String streamUrl, Map<String, String> headers) async {
    try {
      return await _channel.invokeMethod<Uint8List>('generateFromUrl', {
        'url': streamUrl,
        'headers': headers,
      });
    } on PlatformException {
      return null;
    } on MissingPluginException {
      return null; // 平台不支持(Windows)
    }
  }
}

Range 请求分析

实际日志

[STREAM] Request for file: xxx, Range: bytes=0-10485759 → 10MB
[STREAM] Request for file: xxx, Range: bytes=1390125- → 从 1.3MB
[STREAM] Request for file: xxx, Range: bytes=2970856- → 从 2.9MB
[STREAM] Request for file: xxx, Range: bytes=4497590- → 从 4.5MB
[STREAM] Request for file: xxx, Range: bytes=7118966- → 从 7.1MB

数据分析

  • 视频总大小:478 MB
  • 实际下载量:~17 MB(约 3.5%)
  • 生成的缩略图:214 KB JPEG

为什么下载了 17MB 而不是更少?

  1. 读取 moov atom:MP4 需要先读取索引信息
  2. 关键帧定位:MediaMetadataRetriever 需要找到最近的 I-frame
  3. 解码依赖:第5秒的帧可能需要从第0秒的关键帧开始解码

这是 MediaMetadataRetriever 的正常行为,无法进一步优化。

依赖清理

移除 fc_native_video_thumbnail

既然已经使用原生 MethodChannel,上传时也统一使用:

# pubspec.yaml - 删除的依赖
# fc_native_video_thumbnail: ^0.17.2  ← 已移除

优点:

  • 减少一个第三方依赖
  • 统一代码路径
  • fc_native_video_thumbnail 本身也不支持 Windows

最终架构

┌─────────────────────────────────────────────────────────────────┐
│ 缩略图生成最终架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ VideoThumbnailService │ │
│ ├───────────────────────────┬─────────────────────────────┤ │
│ │ generateFromFile() │ generateFromUrl() │ │
│ │ (上传时) │ (按需重生成) │ │
│ └───────────────────────────┴─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ MethodChannel('e2eepan/video_thumbnail') │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ MediaMetadataRetriever (Android) │ │
│ │ │ │
│ │ • 本地文件:setDataSource(filePath) │ │
│ │ • 远程 URL:setDataSource(url, headers) │ │
│ │ • 帧提取:getFrameAtTime(5_000_000, OPTION_CLOSEST_SYNC)│ │
│ │ • 压缩:JPEG 85% │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

跨平台支持

平台上传时生成按需重生成说明
AndroidMediaMetadataRetriever
iOSAVAssetImageGenerator(待实现)
Windows无原生 API,显示默认图标
Linux无原生 API,显示默认图标

经验教训

1. 后台任务不能依赖 UI 渲染

media_kit 的 screenshot() 看起来简单,但底层依赖完整渲染管道。后台服务应使用专门的媒体处理 API。

2. 原生 API 往往更可靠

MediaMetadataRetriever 是 Android 系统级 API:

  • 经过充分测试
  • 自动处理各种视频格式
  • 内置 Range 请求优化

3. Range 请求的实际效果

虽然理论上可以只下载几 MB,但实际受限于:

  • 视频容器结构(moov 位置)
  • 关键帧间隔(GOP)
  • 解码依赖链

17MB vs 478MB 已经是 96% 的节省,可以接受。

4. 选择第5秒而非第0秒

很多视频开头是:

  • 黑屏
  • 片头 logo
  • 转场动画

第5秒更能代表视频内容。如果视频不足5秒,MediaMetadataRetriever 会自动返回最接近的帧。

修改的文件

文件变更
MainActivity.kt添加 MethodChannel 和原生缩略图生成
video_thumbnail_service.dart改用 MethodChannel 调用原生 API
files_page.dart更新注释
pubspec.yaml移除 fc_native_video_thumbnail

后续可优化

  1. iOS 实现:添加 AVAssetImageGenerator 支持
  2. 超时处理:添加生成超时机制
  3. 重试策略:核心启动延迟时的重试