视频缩略图生成方案:从"按需下载"到"上传时生成"

December 20, 2025
5 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.

问题描述

初始需求

在文件列表中显示视频缩略图,提升使用体验。

原始设计(失败)

最初的设计是”按需生成”:

  1. 打开文件列表时,前端请求缩略图
  2. 后端检查是否有缓存的缩略图
  3. 如果没有,返回 needClientGenerate: true 指示前端生成
  4. 前端下载视频的前 N MB 数据,使用原生 API 生成缩略图
  5. 生成后上传到后端缓存

发现的问题

测试中发现:

  • 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. 不增加网络开销:缩略图与文件一起上传,无需后续下载
  3. 简单可靠:无需复杂的后端处理或文件预处理
  4. 向前兼容:新上传的视频都有缩略图,旧视频显示默认图标

实现细节

架构变化

┌─────────────────────────────────────────────────────────┐
│ 上传时生成缩略图 │
├─────────────────────────────────────────────────────────┤
│ │
│ 选择视频文件 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 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.dartdownloadPartialFile下载部分解密数据,不再需要
server.gopartialFile/files/:id/partial 接口
server.godownloadPartialDecrypted部分解密辅助函数

简化的类

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)
 
// 之后:已删除

使用体验

场景新上传的视频旧视频(无缩略图)
缩略图正常显示显示默认视频图标
原因上传时生成上传时未生成
解决方案-重新上传或接受默认图标

总结

经验教训

  1. 不要假设文件格式统一:MP4 的 moov 位置不固定,设计时需考虑
  2. 完整数据优于部分数据:上传时有完整文件,是生成缩略图的最佳时机
  3. 简单方案优先:相比后端 FFmpeg 或 moov 前置,上传时生成最简单

代码质量

  • 删除了 150+ 行废弃代码
  • 简化了前后端接口
  • 统一了缩略图生成逻辑

后续优化方向

  1. 旧视频迁移:可考虑后台任务为旧视频生成缩略图
  2. 后端 FFmpeg:如需支持更多格式,可考虑后端处理
  3. 压缩优化:可优化缩略图质量和大小的平衡