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.

背景

E2EEPAN 是一个端到端加密的网盘应用,所有文件在上传前加密,存储在 S3 上的都是密文。对于视频文件,需要实现:

  1. 流式播放 - 可以边下载边播放,不需要等待整个文件下载完成
  2. 视频缩略图 - 文件列表中显示视频的预览缩略图

技术挑战

加密格式

项目使用分块加密格式:

  • 每个分块固定大小(默认 64KB)
  • 每个分块有独立的 nonce
  • 文件尾部存储加密的元数据
[Block 1: nonce + encrypted data + tag]
[Block 2: nonce + encrypted data + tag]
...
[Block N: nonce + encrypted data + tag]
[Metadata: length + encrypted metadata]

核心问题

  1. 不能直接 Range 请求 - 因为数据是加密的,HTTP Range 请求的字节范围不对应明文范围
  2. 需要完整解密才能播放? - 传统做法需要先下载完整文件再解密,使用体验差
  3. 视频缩略图生成 - 后端没有 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,
    )
}

解密引擎工作原理

  1. 读取一个加密块(nonce + ciphertext + tag)
  2. 使用 AES-GCM 解密
  3. 将明文写入输出流
  4. 重复直到所有块处理完成

前端实现

使用 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);
}

性能优化

缩略图缓存策略

  1. 内存缓存 - 当前会话的缩略图存储在 _thumbnailCache Map 中
  2. 后端缓存 - 生成后上传到 S3 的 thumbs/ 目录,下次直接获取
  3. 懒加载 - 只在文件进入视口时才加载缩略图

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

总结

本方案通过以下设计实现了加密视频的流式播放和缩略图生成:

  1. 流式解密 - 后端逐块解密并流式输出,前端使用 media_kit 直接播放 HTTP 流
  2. 客户端缩略图生成 - 下载 5MB 部分数据,使用平台原生 API 生成缩略图
  3. 缓存机制 - 生成的缩略图上传到后端,避免重复生成

这种设计避免了在移动端部署 FFmpeg 的复杂性,同时保持了良好的使用体验。