问题描述
初始需求
在文件列表中显示视频缩略图,提升使用体验。
原始设计(失败)
最初的设计是”按需生成”:
- 打开文件列表时,前端请求缩略图
- 后端检查是否有缓存的缩略图
- 如果没有,返回
needClientGenerate: true指示前端生成 - 前端下载视频的前 N MB 数据,使用原生 API 生成缩略图
- 生成后上传到后端缓存
发现的问题
测试中发现:
- 34MB 以下的视频文件:缩略图生成成功
- 57MB、59MB 的视频文件:生成失败
错误日志:
E/MediaMetadataRetrieverJNI: getEmbeddedPicture: Call to getEmbeddedPicture failed.I/flutter: Generate video thumbnail locally failed: PlatformException(PluginError, Failed to create thumbnail, null, null)根因分析
MP4 文件结构
MP4 文件由多个 “atom”(也叫 “box”)组成:
- ftyp: 文件类型
- moov: 元数据(包含帧索引、时间戳等)
- mdat: 实际的音视频数据
关键问题:moov atom 的位置不固定
┌─────────────────────────────────────────────────────────┐│ 方案 A: moov 在前 │├─────────────────────────────────────────────────────────┤│ ftyp │ moov (元数据) │ mdat (音视频数据) ││ │ │ ││ ◄────────────────── 可从部分数据生成缩略图 │└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐│ 方案 B: moov 在后 │├─────────────────────────────────────────────────────────┤│ ftyp │ mdat (音视频数据) │ moov (元数据)││ │ │ ││ ◄──────────────────── 无法解析! ││ 下载了 50MB 但没有元数据,无法定位关键帧 │└─────────────────────────────────────────────────────────┘为什么部分下载失败
MediaMetadataRetriever需要先读取moov才能定位关键帧- 如果
moov在文件末尾(常见于非流媒体优化的视频) - 无论下载前 10MB 还是 50MB,都无法获取元数据
验证
| 文件大小 | 缩略图结果 | 分析 |
|---|---|---|
| 20MB | 成功 | 完整下载,moov 可读 |
| 34MB | 成功 | 完整下载,moov 可读 |
| 57MB | 失败 | 只下载了 50MB,moov 在末尾 |
| 59MB | 失败 | 只下载了 50MB,moov 在末尾 |
设计取舍
方案对比
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| A. 增加下载量 | 下载更多数据(100MB+) | 简单 | 浪费带宽,大文件仍可能失败 |
| B. 后端 FFmpeg | 后端用 FFmpeg 生成 | 支持所有格式 | 需要部署 FFmpeg,嵌入式核心不友好 |
| C. moov 前置 | 上传时预处理视频 | 一劳永逸 | 复杂,改变个人文件 |
| D. 上传时生成 | 上传时客户端生成缩略图 | 简单有效 | 旧视频无缩略图 |
| E. 静默失败 | 无缩略图显示默认图标 | 最简单 | 体验差 |
最终选择:方案 D - 上传时生成
理由:
- 客户端有完整文件:上传时,原始视频文件在本地,可以完美生成缩略图
- 不增加网络开销:缩略图与文件一起上传,无需后续下载
- 简单可靠:无需复杂的后端处理或文件预处理
- 向前兼容:新上传的视频都有缩略图,旧视频显示默认图标
实现细节
架构变化
┌─────────────────────────────────────────────────────────┐│ 上传时生成缩略图 │├─────────────────────────────────────────────────────────┤│ ││ 选择视频文件 ││ │ ││ ▼ ││ ┌──────────────────────────────────────────────────┐ ││ │ 1. 上传视频文件到服务器 │ ││ └──────────────────────────────────────────────────┘ ││ │ ││ ▼ 上传成功 ││ ┌──────────────────────────────────────────────────┐ ││ │ 2. 检查是否是视频文件 (isVideoFile) │ ││ └──────────────────────────────────────────────────┘ ││ │ ││ ▼ 是视频 ││ ┌──────────────────────────────────────────────────┐ ││ │ 3. 从本地原始文件生成缩略图 │ ││ │ - 使用 fc_native_video_thumbnail │ ││ │ - 原始文件完整,100% 成功 │ ││ └──────────────────────────────────────────────────┘ ││ │ ││ ▼ ││ ┌──────────────────────────────────────────────────┐ ││ │ 4. 上传缩略图到服务器 (api.uploadThumbnail) │ ││ └──────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────┘关键代码
上传流程 (app_state.dart):
void _startUploadJob(_UploadJob job) {
// ... 上传逻辑 ...
if (result.isSuccess && result.data != null) {
// 上传成功
_files.add(result.data!);
// 视频文件上传成功后,生成并上传缩略图
if (isVideoFile(result.data!.mimeType, result.data!.name)) {
_generateAndUploadVideoThumbnail(
job.filePath, // 本地原始文件路径
result.data!.id,
);
}
}
}
Future<void> _generateAndUploadVideoThumbnail(
String videoPath,
String fileId,
) async {
try {
final tempDir = await getTemporaryDirectory();
final thumbPath = path.join(tempDir.path, 'thumb_$fileId.jpg');
// 从本地完整文件生成缩略图
final plugin = FcNativeVideoThumbnail();
final success = await plugin.getVideoThumbnail(
srcFile: videoPath, // 完整的本地文件
destFile: thumbPath,
width: 256,
height: 256,
format: 'jpeg',
quality: 85,
);
if (success) {
final thumbFile = io.File(thumbPath);
if (await thumbFile.exists()) {
final thumbData = await thumbFile.readAsBytes();
await api.uploadThumbnail(fileId, thumbData);
await thumbFile.delete();
}
}
} catch (_) {
// 缩略图生成失败不影响上传流程
}
}后端简化 (server.go):
func (s *Server) downloadThumbnail(c *gin.Context) {
// 1. 检查缓存的缩略图
data, err := s.s3.DownloadBytes(ctx, s3Key)
if err == nil {
// 有缓存,直接返回
c.Data(http.StatusOK, "image/jpeg", decryptedData)
return
}
// 2. 视频文件应在上传时生成,如果没有则返回 404
if isVideoFile(fileMeta.MimeType, fileMeta.Name) {
c.JSON(http.StatusNotFound, gin.H{"error": "video thumbnail not found"})
return
}
// 3. 图片文件按需生成
// ...
}清理的代码
删除的函数
| 位置 | 函数 | 说明 |
|---|---|---|
| files_page.dart | _generateVideoThumbnailLocally | 从部分数据生成,已废弃 |
| api_client.dart | downloadPartialFile | 下载部分解密数据,不再需要 |
| server.go | partialFile | /files/:id/partial 接口 |
| server.go | downloadPartialDecrypted | 部分解密辅助函数 |
简化的类
ThumbnailResult:
// 之前
class ThumbnailResult {
final List<int>? imageData;
final bool needClientGenerate; // 已删除
final String? fileId; // 已删除
final String? mimeType; // 已删除
final String? error;
}
// 之后
class ThumbnailResult {
final List<int>? imageData;
final String? error;
}删除的 API 路由
// 之前
api.GET("/files/:id/partial", s.partialFile)
// 之后:已删除使用体验
| 场景 | 新上传的视频 | 旧视频(无缩略图) |
|---|---|---|
| 缩略图 | 正常显示 | 显示默认视频图标 |
| 原因 | 上传时生成 | 上传时未生成 |
| 解决方案 | - | 重新上传或接受默认图标 |
总结
经验教训
- 不要假设文件格式统一:MP4 的 moov 位置不固定,设计时需考虑
- 完整数据优于部分数据:上传时有完整文件,是生成缩略图的最佳时机
- 简单方案优先:相比后端 FFmpeg 或 moov 前置,上传时生成最简单
代码质量
- 删除了 150+ 行废弃代码
- 简化了前后端接口
- 统一了缩略图生成逻辑
后续优化方向
- 旧视频迁移:可考虑后台任务为旧视频生成缩略图
- 后端 FFmpeg:如需支持更多格式,可考虑后端处理
- 压缩优化:可优化缩略图质量和大小的平衡