背景
前置问题
在 20251220-211500-video-thumbnail-upload-time-generation.md 中,我们解决了上传时生成视频缩略图的问题。但遗留了一个需求:
旧视频(上传时未生成缩略图)如何重新生成?
技术挑战
- 视频文件是端到端加密存储的,后端无法直接处理
- 客户端需要从远程流式 URL 生成缩略图,而非本地文件
- 需要支持 Range 请求,避免下载完整视频(可能几百 MB)
第一次尝试:media_kit
方案设计
media_kit 是一个跨平台的视频播放库,理论上可以:
- 从 HTTP URL 加载视频(支持 Range 请求)
- Seek 到指定位置
- 截取当前帧作为缩略图
实现代码
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() ◄───────────────────── 渲染缓冲区 ││ │└──────────────────────────────────────────────────────────────────┘问题:
screenshot()从 GPU 渲染缓冲区 读取画面- 没有 VideoController 和 Video Widget = 没有渲染管道
- 后台服务没有 UI = 无法渲染 = 截图永远为空
验证
添加调试日志后发现:
player.stream.width // 始终为 null
player.stream.height // 始终为 null
player.state.position // 在增加(说明视频在播放)
player.screenshot() // 返回 0 bytes结论:media_kit 需要完整的 UI 渲染管道,无法在后台使用。
第二次尝试:Android 原生 API
方案设计
Android 提供了 MediaMetadataRetriever,可以:
- 直接从 HTTP URL 读取视频元数据
- 自动发起 Range 请求,只下载必要数据
- 提取指定时间点的帧,无需 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 而不是更少?
- 读取 moov atom:MP4 需要先读取索引信息
- 关键帧定位:MediaMetadataRetriever 需要找到最近的 I-frame
- 解码依赖:第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% │ ││ └─────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────┘跨平台支持
| 平台 | 上传时生成 | 按需重生成 | 说明 |
|---|---|---|---|
| Android | ✅ | ✅ | MediaMetadataRetriever |
| iOS | ✅ | ✅ | AVAssetImageGenerator(待实现) |
| 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 |
后续可优化
- iOS 实现:添加 AVAssetImageGenerator 支持
- 超时处理:添加生成超时机制
- 重试策略:核心启动延迟时的重试