背景
E2EEPAN 是一个端到端加密的网盘应用,所有文件在上传前加密,存储在 S3 上的都是密文。对于视频文件,需要实现:
- 流式播放 - 可以边下载边播放,不需要等待整个文件下载完成
- 视频缩略图 - 文件列表中显示视频的预览缩略图
技术挑战
加密格式
项目使用分块加密格式:
- 每个分块固定大小(默认 64KB)
- 每个分块有独立的 nonce
- 文件尾部存储加密的元数据
[Block 1: nonce + encrypted data + tag][Block 2: nonce + encrypted data + tag]...[Block N: nonce + encrypted data + tag][Metadata: length + encrypted metadata]核心问题
- 不能直接 Range 请求 - 因为数据是加密的,HTTP Range 请求的字节范围不对应明文范围
- 需要完整解密才能播放? - 传统做法需要先下载完整文件再解密,使用体验差
- 视频缩略图生成 - 后端没有 FFmpeg,无法直接生成缩略图
解决方案
方案架构
┌─────────────────────────────────────────────────────────────┐│ Flutter 客户端 │├─────────────────────────────────────────────────────────────┤│ ││ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ ││ │ media_kit │ │ 缩略图生成 │ │ API Client │ ││ │ 视频播放器 │ │ (原生 API) │ │ │ ││ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ ││ │ │ │ ││ │ HTTP 流式请求 │ 下载部分数据 │ ││ ▼ ▼ ▼ │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Go 核心 (嵌入式) │├─────────────────────────────────────────────────────────────┤│ ││ ┌─────────────────┐ ┌─────────────────────────────────┐││ │ /stream/:id │ │ /files/:id/partial │││ │ 流式解密播放 │ │ 下载部分解密数据(用于缩略图) │││ └────────┬────────┘ └────────────────┬────────────────┘││ │ │ ││ ▼ ▼ ││ ┌─────────────────────────────────────────────────────────┐││ │ 分块解密引擎 │││ │ - 从 S3 下载密文 │││ │ - 逐块解密 │││ │ - 流式输出明文 │││ └─────────────────────────────────────────────────────────┘││ │ │└─────────────────────────────┼───────────────────────────────┘ ▼ ┌───────────────────┐ │ S3 │ │ (加密文件存储) │ └───────────────────┘一、视频流式播放
后端实现
后端已有 /api/v1/stream/:id 接口,实现了流式解密:
func (s *Server) streamFile(c *gin.Context) {
// 1. 从 S3 下载加密文件
encryptedData, err := s.s3.Download(ctx, s3Key)
// 2. 派生文件密钥
fileKey := crypto.DeriveFileKey(s.masterKey, fileID)
// 3. 流式解密(逐块解密并输出)
c.Header("Content-Type", mimeType)
c.Header("Accept-Ranges", "bytes")
err = s.encryptor.DecryptStream(
bytes.NewReader(encryptedData),
c.Writer,
fileKey,
)
}解密引擎工作原理:
- 读取一个加密块(nonce + ciphertext + tag)
- 使用 AES-GCM 解密
- 将明文写入输出流
- 重复直到所有块处理完成
前端实现
使用 media_kit 播放器直接请求流式 URL:
// pubspec.yaml 依赖
dependencies:
media_kit: ^1.1.11
media_kit_video: ^1.2.5
media_kit_libs_video: ^1.0.5
// main.dart 初始化
void main() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
runApp(const MyApp());
}
// VideoPlayerPage 实现
class VideoPlayerPage extends StatefulWidget {
final String fileId;
final String fileName;
// ...
}
class _VideoPlayerPageState extends State<VideoPlayerPage> {
late final Player _player;
late final VideoController _controller;
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
// 获取流式播放 URL 并打开
final streamUrl = widget.api.getStreamUrl(widget.fileId);
_player.open(Media(streamUrl));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.fileName)),
body: Video(controller: _controller),
);
}
}media_kit 优势:
- 支持 HTTP 流式播放
- 跨平台(Android、iOS、Windows、macOS、Linux)
- 硬件加速解码
- 支持多种视频格式
二、视频缩略图生成
由于后端环境(尤其是移动端嵌入式核心)没有 FFmpeg,选择在客户端使用原生 API 生成缩略图。
方案选择
| 方案 | 优点 | 缺点 |
|---|---|---|
| 后端 FFmpeg | 一次生成,多端共享 | 需要部署 FFmpeg,移动端不可行 |
| 客户端 FFmpeg | 功能强大 | 包体积大,授权问题 |
| 客户端原生 API | 轻量,无授权问题 | 需要下载部分视频数据 |
最终选择:fc_native_video_thumbnail 包,使用平台原生 API:
- Android:
MediaMetadataRetriever - iOS:
AVAssetImageGenerator
后端:部分数据下载接口
新增 /api/v1/files/:id/partial 接口,只下载并解密前 N 字节:
func (s *Server) partialFile(c *gin.Context) {
// 获取最大字节数参数,默认 5MB
maxBytesStr := c.Query("maxBytes")
maxBytes := int64(5 * 1024 * 1024)
if maxBytesStr != "" {
if mb, err := strconv.ParseInt(maxBytesStr, 10, 64); err == nil && mb > 0 {
maxBytes = mb
}
}
// 下载部分解密数据
data, mimeType, err := s.downloadPartialDecrypted(ctx, fileID, maxBytes)
c.Data(http.StatusOK, mimeType, data)
}
func (s *Server) downloadPartialDecrypted(ctx context.Context, fileID string, maxBytes int64) ([]byte, string, error) {
// 从 S3 下载完整加密文件
encryptedData, err := s.s3.Download(ctx, s3Key)
// 逐块解密,直到达到 maxBytes
var result bytes.Buffer
for {
// 解密一个块
plainBlock, err := s.encryptor.DecryptBlock(...)
result.Write(plainBlock)
if result.Len() >= int(maxBytes) {
break
}
}
return result.Bytes()[:maxBytes], mimeType, nil
}后端:缩略图接口修改
修改 downloadThumbnail 接口,对视频文件返回特殊响应:
func (s *Server) downloadThumbnail(c *gin.Context) {
// 获取文件元数据
fileMeta, err := s.getFileMetadata(ctx, fileID)
// 视频文件需要客户端生成缩略图
if strings.HasPrefix(fileMeta.MimeType, "video/") {
c.JSON(http.StatusOK, gin.H{
"needClientGenerate": true,
"fileId": fileID,
"mimeType": fileMeta.MimeType,
})
return
}
// 其他文件类型...返回已有缩略图或生成
}前端:缩略图生成流程
Future<Uint8List?> _generateVideoThumbnailLocally(
ApiClient api,
String fileId,
) async {
try {
// 1. 下载部分视频数据(5MB)
final partialResult = await api.downloadPartialFile(fileId);
if (!partialResult.isSuccess || partialResult.data == null) {
return null;
}
// 2. 保存到临时文件
final tempDir = await path_provider.getTemporaryDirectory();
final tempVideoPath = path.join(tempDir.path, 'video_$fileId.tmp');
final tempThumbPath = path.join(tempDir.path, 'thumb_$fileId.jpg');
final videoFile = File(tempVideoPath);
await videoFile.writeAsBytes(partialResult.data!);
// 3. 使用原生 API 生成缩略图
final plugin = FcNativeVideoThumbnail();
final success = await plugin.getVideoThumbnail(
srcFile: tempVideoPath,
destFile: tempThumbPath,
width: 256,
height: 256,
format: 'jpeg',
quality: 85,
);
if (!success) {
return null;
}
// 4. 读取生成的缩略图
final thumbFile = File(tempThumbPath);
final thumbData = await thumbFile.readAsBytes();
// 5. 清理临时文件
await videoFile.delete();
await thumbFile.delete();
// 6. 上传缩略图到后端缓存
if (thumbData.isNotEmpty) {
await api.uploadThumbnail(fileId, thumbData);
}
return thumbData;
} catch (e) {
debugPrint('Video thumbnail generation failed: $e');
return null;
}
}缩略图显示
在文件列表中,视频缩略图上叠加播放图标:
Widget _buildLeading(FileMetadata file) {
final isVideo = file.mimeType.startsWith('video/');
if (_thumbnailCache.containsKey(file.id)) {
return Stack(
children: [
Image.memory(_thumbnailCache[file.id]!, fit: BoxFit.cover),
if (isVideo)
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 16,
),
),
),
),
],
);
}
// 显示占位图标
return Icon(isVideo ? Icons.videocam : Icons.insert_drive_file);
}性能优化
缩略图缓存策略
- 内存缓存 - 当前会话的缩略图存储在
_thumbnailCacheMap 中 - 后端缓存 - 生成后上传到 S3 的
thumbs/目录,下次直接获取 - 懒加载 - 只在文件进入视口时才加载缩略图
5MB 部分下载的选择
为什么选择 5MB:
- 大多数视频的关键帧在前几 MB 内
- 足够生成清晰的缩略图
- 不会造成过大的网络开销
依赖版本
# pubspec.yaml
dependencies:
# 视频播放
media_kit: ^1.1.11
media_kit_video: ^1.2.5
media_kit_libs_video: ^1.0.5
# 视频缩略图
fc_native_video_thumbnail: ^0.17.2
# 辅助
path_provider: ^2.1.1
path: ^1.8.3总结
本方案通过以下设计实现了加密视频的流式播放和缩略图生成:
- 流式解密 - 后端逐块解密并流式输出,前端使用 media_kit 直接播放 HTTP 流
- 客户端缩略图生成 - 下载 5MB 部分数据,使用平台原生 API 生成缩略图
- 缓存机制 - 生成的缩略图上传到后端,避免重复生成
这种设计避免了在移动端部署 FFmpeg 的复杂性,同时保持了良好的使用体验。