类型: 功能实现
状态: 已完成
背景
项目需要在桌面端(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- 缩略图服务
经验总结
- Windows 外部命令窗口问题:使用
SysProcAttr.HideWindow+CREATE_NO_WINDOW标志 - 跨平台代码:使用 Go build tags(
//go:build windows)实现平台特定逻辑 - FFmpeg 参数顺序:
-ss放在-i前面可以实现快速 seek,性能更好 - 精简依赖:只分发必要的
ffmpeg.exe,节省约 2/3 空间