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

类型: 功能实现
状态: 已完成

背景

项目需要在桌面端(Windows/Linux/macOS)支持视频缩略图生成。Android 端使用原生 API(MediaMetadataRetriever),但桌面端没有对应的系统 API。

方案对比

方案优点缺点
FFmpeg(选定)成熟稳定、格式支持全、命令行调用简单需要分发 ffmpeg.exe(~95MB)
GStreamer功能强大配置复杂、体积更大
OpenCV编程接口友好需要编译、依赖多
纯 Go 库无外部依赖格式支持有限、性能差

架构设计

┌─────────────────────────────────────────────────────────────┐
│ Flutter Client │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ VideoThumbnailService │ │
│ │ ┌─────────────────┐ ┌────────────────────────┐ │ │
│ │ │ Android/iOS │ │ Windows/Linux/macOS │ │ │
│ │ │ (Native API) │ │ (FFmpeg via Core API) │ │ │
│ │ └─────────────────┘ └────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▼ POST /api/v1/videos/thumbnail/generate
┌─────────────────────────────────────────────────────────────┐
│ Go Core │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ generateVideoThumbnail() │ │
│ │ 1. findFFmpegPath() - 查找 ffmpeg 可执行文件 │ │
│ │ 2. exec.CommandContext() - 执行 ffmpeg 命令 │ │
│ │ 3. hideCommandWindow() - 隐藏 Windows 命令行窗口 │ │
│ │ 4. 返回 JPEG 字节流 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▼ subprocess
┌─────────────────────────────────────────────────────────────┐
│ ffmpeg -ss 5 -i video.mp4 -vframes 1 -vf "scale=256:-1" │
│ -q:v 2 -y output.jpg │
└─────────────────────────────────────────────────────────────┘

实现细节

1. Go Core API 端点

文件: core/internal/api/server.go

// POST /api/v1/videos/thumbnail/generate
// 请求体: {"filePath": "...", "seekSecond": 5, "maxSize": 256}
// 响应: JPEG 图片字节流
api.POST("/videos/thumbnail/generate", s.generateVideoThumbnail)

2. FFmpeg 路径查找策略

func findFFmpegPath() (string, error) {
    // 1. 优先检查核心同目录(便于分发)
    exePath, _ := os.Executable()
    exeDir := filepath.Dir(exePath)
    localPath := filepath.Join(exeDir, "ffmpeg.exe") // Windows
    if _, err := os.Stat(localPath); err == nil {
        return localPath, nil
    }
    
    // 2. 回退到系统 PATH
    path, err := exec.LookPath("ffmpeg")
    if err == nil {
        return path, nil
    }
    
    return "", fmt.Errorf("FFmpeg not found")
}

3. FFmpeg 命令执行

func generateVideoThumbnailWithFFmpeg(ffmpegPath, videoPath string, 
    seekSecond, maxSize int) ([]byte, error) {
    
    // 创建临时文件存储输出
    tempFile, _ := os.CreateTemp("", "thumb_*.jpg")
    tempPath := tempFile.Name()
    tempFile.Close()
    defer os.Remove(tempPath)
    
    // FFmpeg 参数
    args := []string{
        "-ss", fmt.Sprintf("%d", seekSecond), // 快速 seek
        "-i", videoPath,
        "-vframes", "1",                       // 只取一帧
        "-vf", fmt.Sprintf("scale=%d:-1", maxSize), // 缩放
        "-q:v", "2",                           // JPEG 质量
        "-y", tempPath,
    }
    
    // 30 秒超时
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    cmd := exec.CommandContext(ctx, ffmpegPath, args...)
    hideCommandWindow(cmd) // Windows 隐藏窗口
    
    if err := cmd.Run(); err != nil {
        return nil, err
    }
    
    return os.ReadFile(tempPath)
}

4. Windows 隐藏命令行窗口

执行外部程序时,Windows 默认会弹出命令行窗口。需要使用平台特定代码隐藏:

文件: core/internal/api/cmd_windows.go

//go:build windows
 
package api
 
import (
    "os/exec"
    "syscall"
)
 
func hideCommandWindow(cmd *exec.Cmd) {
    cmd.SysProcAttr = &syscall.SysProcAttr{
        HideWindow:    true,
        CreationFlags: 0x08000000, // CREATE_NO_WINDOW
    }
}

文件: core/internal/api/cmd_unix.go

//go:build !windows
 
package api
 
import "os/exec"
 
func hideCommandWindow(cmd *exec.Cmd) {
    // Unix 系统不需要特殊处理
}

5. 客户端调用

文件: client/lib/core/services/video_thumbnail_service.dart

Future<Uint8List?> generateFromFile(String filePath) async {
  // 桌面端使用 FFmpeg API
  if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
    return _generateFromFileWithFFmpeg(filePath);
  }
  // Android/iOS 使用原生 API
  return _generateFromFileNative(filePath);
}
 
Future<Uint8List?> _generateFromFileWithFFmpeg(String filePath) async {
  final result = await _apiClient.generateVideoThumbnail(
    filePath: filePath,
    seekSecond: 5,
    maxSize: 256,
  );
  return result;
}

文件: client/lib/core/api/api_client.dart

Future<Uint8List?> generateVideoThumbnail({
  required String filePath,
  int seekSecond = 5,
  int maxSize = 256,
}) async {
  final response = await _dio.post<List<int>>(
    '/api/v1/videos/thumbnail/generate',
    data: {
      'filePath': filePath,
      'seekSecond': seekSecond,
      'maxSize': maxSize,
    },
    options: Options(responseType: ResponseType.bytes),
  );
  return Uint8List.fromList(response.data!);
}

FFmpeg 分发说明

FFmpeg 有三个可执行文件,只需要 ffmpeg.exe

文件用途是否需要
ffmpeg.exe视频/音频转换、截图✅ 需要
ffplay.exe播放器❌ 不需要
ffprobe.exe媒体信息分析❌ 不需要

部署位置:放在 e2eepan-core.exe 同目录

体积优化(后续):

  • 使用 UPX 压缩:90MB → ~30MB
  • 自定义编译最小版:可做到 5-10MB

测试结果

  • ✅ 上传视频时自动生成缩略图
  • ✅ 缩略图正确显示在文件列表
  • ✅ 无命令行窗口闪烁
  • ✅ 超时机制正常工作

相关文件

  • core/internal/api/server.go - API 端点和 FFmpeg 调用
  • core/internal/api/cmd_windows.go - Windows 隐藏窗口
  • core/internal/api/cmd_unix.go - Unix 空实现
  • client/lib/core/api/api_client.dart - 客户端 API 方法
  • client/lib/core/services/video_thumbnail_service.dart - 缩略图服务

经验总结

  1. Windows 外部命令窗口问题:使用 SysProcAttr.HideWindow + CREATE_NO_WINDOW 标志
  2. 跨平台代码:使用 Go build tags(//go:build windows)实现平台特定逻辑
  3. FFmpeg 参数顺序-ss 放在 -i 前面可以实现快速 seek,性能更好
  4. 精简依赖:只分发必要的 ffmpeg.exe,节省约 2/3 空间