This commit is contained in:
xiangbing 2026-01-08 22:12:20 +08:00
commit 7717bf13a1
19 changed files with 1165 additions and 88 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -20,8 +20,8 @@
height: 100vh; height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="./assets/index-DSuQhuGl.js"></script> <script type="module" crossorigin src="./assets/index-IwiMqFON.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BDrFF8AO.css"> <link rel="stylesheet" crossorigin href="./assets/index-BaD48VVT.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

14
wails/ffmpeg_darwin.go Normal file
View File

@ -0,0 +1,14 @@
//go:build darwin
package main
import (
"embed"
)
//go:embed resources/ffmpeg/darwin/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

14
wails/ffmpeg_default.go Normal file
View File

@ -0,0 +1,14 @@
//go:build !darwin && !windows && !linux
package main
import (
"embed"
)
//go:embed resources/ffmpeg
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

14
wails/ffmpeg_linux.go Normal file
View File

@ -0,0 +1,14 @@
//go:build linux
package main
import (
"embed"
)
//go:embed resources/ffmpeg/linux/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

14
wails/ffmpeg_windows.go Normal file
View File

@ -0,0 +1,14 @@
//go:build windows
package main
import (
"embed"
)
//go:embed resources/ffmpeg/windows/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

View File

@ -4,13 +4,18 @@ import (
"embed" "embed"
"os" "os"
"github.com/wailsapp/wails/v3/pkg/application"
"videoconcat/services" "videoconcat/services"
"github.com/wailsapp/wails/v3/pkg/application"
) )
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// getEmbeddedFFmpeg 在平台特定文件中实现ffmpeg_darwin.go, ffmpeg_windows.go, ffmpeg_linux.go, ffmpeg_default.go
// 根据编译时的 GOOS 自动选择对应的实现
// 注意:此函数没有在此文件中声明,而是在平台特定文件中声明和实现
func main() { func main() {
// 检测开发模式并设置日志级别 // 检测开发模式并设置日志级别
if os.Getenv("DEV") == "true" { if os.Getenv("DEV") == "true" {
@ -19,6 +24,9 @@ func main() {
services.LogDebug("详细日志已启用") services.LogDebug("详细日志已启用")
} }
// 初始化 FFmpeg 助手(传递嵌入的文件系统,根据编译平台自动选择)
services.InitFFmpegHelper(getEmbeddedFFmpeg())
// 创建服务 // 创建服务
services.LogDebug("开始创建服务...") services.LogDebug("开始创建服务...")
videoService := services.NewVideoService() videoService := services.NewVideoService()
@ -47,9 +55,9 @@ func main() {
// 创建窗口 // 创建窗口
services.LogDebug("创建应用窗口...") services.LogDebug("创建应用窗口...")
window := app.Window.NewWithOptions(application.WebviewWindowOptions{ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "视频拼接工具", Title: "视频拼接工具",
Width: 1100, Width: 1100,
Height: 800, Height: 800,
MinWidth: 800, MinWidth: 800,
MinHeight: 600, MinHeight: 600,
}) })

31
wails/resources/ffmpeg/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# 忽略 FFmpeg 二进制文件(文件太大,通常不应提交到 Git
# 用户需要自行下载并放置二进制文件到编译时嵌入
# 根目录的二进制文件(向后兼容)
ffmpeg
ffprobe
ffplay
ffmpeg.exe
ffprobe.exe
ffplay.exe
# 按平台组织的目录中的二进制文件
darwin/ffmpeg
darwin/ffprobe
darwin/ffplay
windows/ffmpeg.exe
windows/ffprobe.exe
windows/ffplay.exe
linux/ffmpeg
linux/ffprobe
linux/ffplay
# 但保留 README.md、.gitkeep 和目录结构
!README.md
!.gitkeep
!darwin/
!windows/
!linux/
!darwin/.gitkeep
!windows/.gitkeep
!linux/.gitkeep

View File

@ -0,0 +1 @@
# 保留此目录在 Git 中

View File

@ -0,0 +1,158 @@
# FFmpeg 二进制文件放置说明
此目录用于存放 FFmpeg 和 FFprobe 的二进制文件。这些文件会被**嵌入到 Go 程序**中,随应用一起分发。
**重要**:二进制文件会被编译到最终的可执行文件中,因此文件大小会增加。建议使用静态构建版本以减小体积。
## 目录结构
### ⭐ 推荐:按平台组织(实现自动平台选择)
```
resources/
└── ffmpeg/
├── darwin/ # macOS 版本(编译 macOS 时自动嵌入)
│ ├── ffmpeg
│ └── ffprobe
├── windows/ # Windows 版本(编译 Windows 时自动嵌入)
│ ├── ffmpeg.exe
│ └── ffprobe.exe
└── linux/ # Linux 版本(编译 Linux 时自动嵌入)
├── ffmpeg
└── ffprobe
```
**优势**
- ✅ 每个平台只嵌入对应平台的二进制文件
- ✅ 减小最终可执行文件大小
- ✅ 避免跨平台打包时的混乱
- ✅ 使用 Go 构建标签自动选择
### 选项 2所有平台通用向后兼容
```
resources/
└── ffmpeg/
├── ffmpeg # 当前平台的 ffmpegmacOS/Linux
├── ffmpeg.exe # 或 Windows 版本
├── ffprobe # 当前平台的 ffprobemacOS/Linux
└── ffprobe.exe # 或 Windows 版本
```
**注意**:这种方式会嵌入所有文件,增加不必要的体积。
## 获取 FFmpeg 二进制文件
### macOS
```bash
# 使用 Homebrew 安装
brew install ffmpeg
# 复制到资源目录
cp /opt/homebrew/bin/ffmpeg resources/ffmpeg/ffmpeg
cp /opt/homebrew/bin/ffprobe resources/ffmpeg/ffprobe
# 或者如果是 Intel Mac
cp /usr/local/bin/ffmpeg resources/ffmpeg/ffmpeg
cp /usr/local/bin/ffprobe resources/ffmpeg/ffprobe
```
### Windows
1. 下载 FFmpeg Windows 构建版本:
- 访问 https://www.gyan.dev/ffmpeg/builds/
- 下载 ffmpeg-release-essentials.zip
- 解压后找到 `bin` 目录下的 `ffmpeg.exe``ffprobe.exe`
2. 复制到资源目录:
```powershell
copy ffmpeg.exe resources\ffmpeg\ffmpeg.exe
copy ffprobe.exe resources\ffmpeg\ffprobe.exe
```
### Linux
```bash
# Ubuntu/Debian
sudo apt-get install ffmpeg
# 复制到资源目录
cp /usr/bin/ffmpeg resources/ffmpeg/ffmpeg
cp /usr/bin/ffprobe resources/ffmpeg/ffprobe
```
或者下载静态构建版本:
```bash
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
tar xf ffmpeg-release-amd64-static.tar.xz
cp ffmpeg-*-amd64-static/ffmpeg resources/ffmpeg/ffmpeg
cp ffmpeg-*-amd64-static/ffprobe resources/ffmpeg/ffprobe
```
## 注意事项
1. **文件权限**:在 macOS/Linux 上,确保二进制文件有执行权限:
```bash
chmod +x resources/ffmpeg/ffmpeg
chmod +x resources/ffmpeg/ffprobe
```
2. **架构匹配**
- macOS确保使用正确的架构Intel 或 Apple Silicon
- Linux确保使用正确的架构amd64、arm64 等)
3. **依赖库**:静态构建版本通常更好,因为不需要外部依赖库。
4. **打包时**:在打包应用时,确保将此目录的内容包含到应用包中。
## 应用程序查找顺序
1. **系统 PATH**:首先检查系统 PATH 中是否有 ffmpeg/ffprobe
2. **嵌入资源**:如果未找到,从嵌入的 Go 资源中提取并解压到临时目录
3. **文件系统资源**:最后尝试从打包的资源目录查找(如果嵌入资源未找到)
## 嵌入和使用方式
### 工作原理
1. **编译时**
- 使用 Go 构建标签build tags根据目标平台自动选择嵌入文件
- macOS 构建时:只嵌入 `resources/ffmpeg/darwin/*`
- Windows 构建时:只嵌入 `resources/ffmpeg/windows/*`
- Linux 构建时:只嵌入 `resources/ffmpeg/linux/*`
2. **运行时**:首次使用时,从嵌入的文件系统中提取二进制文件到临时目录
3. **缓存**:提取的文件会缓存在临时目录中,避免重复提取
### 构建标签说明
项目使用以下文件实现平台特定嵌入:
- `ffmpeg_darwin.go` - macOS 平台(使用 `//go:build darwin`
- `ffmpeg_windows.go` - Windows 平台(使用 `//go:build windows`
- `ffmpeg_linux.go` - Linux 平台(使用 `//go:build linux`
- `ffmpeg_default.go` - 其他平台(向后兼容)
编译时Go 会自动只编译匹配当前平台的文件,因此:
- 在 macOS 上编译 → 只嵌入 darwin 目录的文件
- 在 Windows 上编译 → 只嵌入 windows 目录的文件
- 在 Linux 上编译 → 只嵌入 linux 目录的文件
### 注意事项
- **文件大小**:二进制文件会被嵌入到最终可执行文件中,会增加程序大小
- **首次运行**:首次运行时需要提取二进制文件,可能会有短暂的延迟
- **临时目录**:提取的文件保存在 `{临时目录}/videoconcat-ffmpeg/{进程ID}/`
- **权限**提取的文件会自动设置执行权限Unix 系统)
- **清理**:临时文件在程序退出后通常会被系统清理,但进程异常退出时可能需要手动清理
## Git 提交注意事项
默认情况下,`.gitignore` 已配置忽略二进制文件。如果需要将二进制文件提交到 Git
1. 移除 `.gitignore` 中的相关规则,或
2. 使用 `git add -f` 强制添加
**注意**:二进制文件通常很大(几十到几百 MB可能不适合提交到 Git。建议使用构建脚本在编译时自动下载。

View File

@ -0,0 +1,5 @@
# macOS 平台的 FFmpeg 二进制文件放置目录
#
# 将 macOS 版本的 ffmpeg 和 ffprobe 放在此目录下:
# - ffmpeg
# - ffprobe

View File

@ -0,0 +1,5 @@
# Linux 平台的 FFmpeg 二进制文件放置目录
#
# 将 Linux 版本的 ffmpeg 和 ffprobe 放在此目录下:
# - ffmpeg
# - ffprobe

View File

@ -0,0 +1,5 @@
# Windows 平台的 FFmpeg 二进制文件放置目录
#
# 将 Windows 版本的 ffmpeg.exe 和 ffprobe.exe 放在此目录下:
# - ffmpeg.exe
# - ffprobe.exe

View File

@ -0,0 +1,220 @@
# FFmpeg 嵌入使用说明
## 快速开始
### 1. 下载 FFmpeg 二进制文件
根据你的目标平台下载对应的 FFmpeg 静态构建版本,并放置到对应的平台目录下:
**重要**:使用平台特定目录组织,编译时会自动只嵌入对应平台的版本。
#### macOS (Apple Silicon 或 Intel)
```bash
# 使用 Homebrew 安装(推荐)
brew install ffmpeg
# 复制到 macOS 专用目录
cp /opt/homebrew/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
cp /opt/homebrew/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
# 或者如果是 Intel Mac
cp /usr/local/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
cp /usr/local/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
# 设置权限
chmod +x wails/resources/ffmpeg/darwin/ffmpeg
chmod +x wails/resources/ffmpeg/darwin/ffprobe
```
**或者下载静态构建版本:**
```bash
# Apple Silicon
wget https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip
unzip ffmpeg-6.1.zip
cp ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
cp ffprobe wails/resources/ffmpeg/darwin/ffprobe
chmod +x wails/resources/ffmpeg/darwin/ffmpeg wails/resources/ffmpeg/darwin/ffprobe
```
#### Windows
```powershell
# 下载
# 访问 https://www.gyan.dev/ffmpeg/builds/
# 下载 ffmpeg-release-essentials.zip
# 解压后复制 bin 目录下的文件到 windows 目录
copy ffmpeg.exe wails\resources\ffmpeg\windows\ffmpeg.exe
copy ffprobe.exe wails\resources\ffmpeg\windows\ffprobe.exe
```
#### Linux
```bash
# 下载静态构建版本
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
tar xf ffmpeg-release-amd64-static.tar.xz
cd ffmpeg-*-amd64-static
# 复制到 Linux 专用目录
cp ffmpeg ../../wails/resources/ffmpeg/linux/ffmpeg
cp ffprobe ../../wails/resources/ffmpeg/linux/ffprobe
chmod +x ../../wails/resources/ffmpeg/linux/ffmpeg
chmod +x ../../wails/resources/ffmpeg/linux/ffprobe
```
### 2. 编译应用
二进制文件会在编译时根据目标平台自动选择并嵌入:
```bash
cd wails
# 在 macOS 上编译 macOS 版本(只嵌入 darwin/ 目录的文件)
go build -o VideoConcat
# 交叉编译 Windows 版本(只嵌入 windows/ 目录的文件)
GOOS=windows GOARCH=amd64 go build -o VideoConcat.exe
# 交叉编译 Linux 版本(只嵌入 linux/ 目录的文件)
GOOS=linux GOARCH=amd64 go build -o VideoConcat
```
**优势**
- ✅ macOS 版本只包含 macOS 的 FFmpeg体积更小
- ✅ Windows 版本只包含 Windows 的 FFmpeg体积更小
- ✅ 交叉编译时不会混入其他平台的二进制文件
### 3. 运行应用
应用启动时会自动:
1. 检查系统 PATH 中的 ffmpeg
2. 如果未找到,从嵌入的资源中提取到临时目录
3. 使用提取的二进制文件
## 工作原理
### 嵌入过程
```go
// main.go
//go:embed resources/ffmpeg
var embeddedFFmpeg embed.FS
```
这行代码会在编译时将所有 `resources/ffmpeg/` 目录下的文件嵌入到程序中。
### 运行时提取
应用首次启动时:
1. 检查系统是否有 ffmpeg
2. 如果没有,从嵌入的文件系统中查找匹配的二进制文件
3. 提取到临时目录:`{临时目录}/videoconcat-ffmpeg/{进程ID}/`
4. 设置执行权限
5. 使用提取的文件
### 文件查找顺序
1. `resources/ffmpeg/ffmpeg``resources/ffmpeg/ffmpeg.exe`
2. `resources/ffmpeg/{GOOS}/ffmpeg``resources/ffmpeg/{GOOS}/ffmpeg.exe`
3. `resources/ffmpeg/{GOOS}_{GOARCH}/ffmpeg`
## 目录结构示例
### ⭐ 推荐:按平台组织(自动平台选择)
```
wails/resources/ffmpeg/
├── darwin/ # macOS 版本
│ ├── ffmpeg
│ └── ffprobe
├── windows/ # Windows 版本
│ ├── ffmpeg.exe
│ └── ffprobe.exe
├── linux/ # Linux 版本
│ ├── ffmpeg
│ └── ffprobe
└── README.md
```
**编译行为**
- 在 macOS 上编译 → 只嵌入 `darwin/` 目录
- 在 Windows 上编译 → 只嵌入 `windows/` 目录
- 在 Linux 上编译 → 只嵌入 `linux/` 目录
### 向后兼容:单目录结构
```
wails/resources/ffmpeg/
├── ffmpeg # 当前平台的 ffmpeg不推荐
├── ffprobe # 当前平台的 ffprobe
└── README.md
```
**注意**:这种方式会嵌入所有文件,不推荐使用。
## 注意事项
### 文件大小
- FFmpeg 静态构建版本通常 50-200 MB
- 嵌入后会增加最终可执行文件的大小
- 考虑使用 LZMA 压缩或分平台构建来减小体积
### Git 提交
默认情况下,二进制文件被 `.gitignore` 忽略。如果需要提交:
```bash
# 方法1强制添加
git add -f wails/resources/ffmpeg/ffmpeg
# 方法2修改 .gitignore不推荐文件太大
```
### 权限问题
在 macOS/Linux 上,确保文件有执行权限:
```bash
chmod +x wails/resources/ffmpeg/ffmpeg
chmod +x wails/resources/ffmpeg/ffprobe
```
### 临时文件清理
提取的文件保存在临时目录中。如果进程异常退出,可能需要手动清理:
```bash
# macOS/Linux
rm -rf /tmp/videoconcat-ffmpeg
# Windows
rd /s /q %TEMP%\videoconcat-ffmpeg
```
## 故障排查
### 问题:找不到 ffmpeg
**检查清单:**
1. 确认二进制文件已放置在 `wails/resources/ffmpeg/` 目录
2. 检查文件名是否正确Windows 需要 `.exe` 后缀)
3. 检查文件权限macOS/Linux
4. 查看应用日志,查找详细的错误信息
### 问题:权限被拒绝
**解决方案:**
```bash
chmod +x wails/resources/ffmpeg/ffmpeg
chmod +x wails/resources/ffmpeg/ffprobe
```
### 问题:提取失败
**可能原因:**
- 临时目录不可写
- 磁盘空间不足
- 文件损坏
**检查:**
- 查看应用日志
- 检查临时目录权限和空间

View File

@ -0,0 +1,149 @@
# 平台特定 FFmpeg 嵌入说明
## 概述
项目使用 **Go 构建标签Build Tags** 实现平台特定的 FFmpeg 嵌入,确保每个平台的构建只包含对应平台的二进制文件。
## 工作原理
### 构建标签文件
项目包含以下平台特定文件:
- `ffmpeg_darwin.go` - macOS 平台(`//go:build darwin`
- `ffmpeg_windows.go` - Windows 平台(`//go:build windows`
- `ffmpeg_linux.go` - Linux 平台(`//go:build linux`
- `ffmpeg_default.go` - 其他平台(向后兼容)
### 编译时的行为
Go 编译器会根据当前编译目标平台(`GOOS`)自动选择匹配的文件:
| 编译平台 | 使用文件 | 嵌入目录 | 结果 |
|---------|---------|---------|------|
| `GOOS=darwin` | `ffmpeg_darwin.go` | `resources/ffmpeg/darwin/*` | 只包含 macOS 版本 |
| `GOOS=windows` | `ffmpeg_windows.go` | `resources/ffmpeg/windows/*` | 只包含 Windows 版本 |
| `GOOS=linux` | `ffmpeg_linux.go` | `resources/ffmpeg/linux/*` | 只包含 Linux 版本 |
## 目录结构
```
wails/
├── main.go # 主文件(声明 getEmbeddedFFmpeg 函数)
├── ffmpeg_darwin.go # macOS 实现
├── ffmpeg_windows.go # Windows 实现
├── ffmpeg_linux.go # Linux 实现
├── ffmpeg_default.go # 默认实现(向后兼容)
└── resources/
└── ffmpeg/
├── darwin/ # macOS 二进制文件
│ ├── ffmpeg
│ └── ffprobe
├── windows/ # Windows 二进制文件
│ ├── ffmpeg.exe
│ └── ffprobe.exe
└── linux/ # Linux 二进制文件
├── ffmpeg
└── ffprobe
```
## 使用方法
### 1. 准备二进制文件
将各平台的 FFmpeg 二进制文件放置到对应目录:
```bash
# macOS
cp /opt/homebrew/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
cp /opt/homebrew/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
# Windows
copy ffmpeg.exe wails\resources\ffmpeg\windows\ffmpeg.exe
copy ffprobe.exe wails\resources\ffmpeg\windows\ffprobe.exe
# Linux
cp ffmpeg wails/resources/ffmpeg/linux/ffmpeg
cp ffprobe wails/resources/ffmpeg/linux/ffprobe
```
### 2. 编译不同平台
```bash
# macOS 版本(只嵌入 darwin/ 目录)
GOOS=darwin go build -o VideoConcat-mac
# Windows 版本(只嵌入 windows/ 目录)
GOOS=windows GOARCH=amd64 go build -o VideoConcat.exe
# Linux 版本(只嵌入 linux/ 目录)
GOOS=linux GOARCH=amd64 go build -o VideoConcat-linux
```
### 3. 验证嵌入内容
编译后,可以使用以下方法验证:
```bash
# macOS/Linux
strings VideoConcat | grep -i ffmpeg | head -5
# Windows (使用 PowerShell)
Select-String -Path VideoConcat.exe -Pattern "ffmpeg" | Select-Object -First 5
```
## 优势
### 1. 减小文件体积
- 只嵌入当前平台的二进制文件
- 减少不必要的文件大小增加
- 示例:如果每个平台 FFmpeg 是 50MB使用平台特定嵌入后
- 之前:所有平台 = 150MB三个平台都包含
- 现在:每个平台 = 50MB只包含对应平台
### 2. 避免交叉编译问题
- 编译 Windows 版本时不会误嵌入 macOS 版本
- 编译 Linux 版本时不会误嵌入 Windows 版本
- 避免运行时找不到正确文件的问题
### 3. 清晰的目录组织
- 各平台文件分开管理
- 易于维护和更新
- 符合 Go 的最佳实践
## 故障排查
### 问题:编译时找不到文件
**原因**:对应平台的目录中没有二进制文件
**解决**
```bash
# 检查文件是否存在
ls -la wails/resources/ffmpeg/darwin/
ls -la wails/resources/ffmpeg/windows/
ls -la wails/resources/ffmpeg/linux/
```
### 问题:交叉编译时仍嵌入所有文件
**原因**:使用了旧的目录结构(根目录下的文件)
**解决**:确保使用平台特定目录(`darwin/`, `windows/`, `linux/`
### 问题:运行时找不到 FFmpeg
**检查**
1. 查看应用日志,确认 FFmpeg 查找路径
2. 检查临时目录中是否成功提取
3. 验证二进制文件是否有执行权限
## 向后兼容
如果不想使用平台特定目录,仍然可以使用根目录放置文件(`resources/ffmpeg/ffmpeg`),但这样会:
- 嵌入所有平台的二进制文件
- 增加不必要的文件大小
- 不推荐使用

View File

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@ -24,16 +23,16 @@ func NewExtractService() *ExtractService {
// ExtractFrameRequest 抽帧请求 // ExtractFrameRequest 抽帧请求
type ExtractFrameRequest struct { type ExtractFrameRequest struct {
FolderPath string `json:"folderPath"` FolderPath string `json:"folderPath"`
ExtractCount int `json:"extractCount"` // 每个视频生成的数量 ExtractCount int `json:"extractCount"` // 每个视频生成的数量
} }
// ExtractFrameResult 抽帧结果 // ExtractFrameResult 抽帧结果
type ExtractFrameResult struct { type ExtractFrameResult struct {
VideoPath string `json:"videoPath"` VideoPath string `json:"videoPath"`
OutputPath string `json:"outputPath"` OutputPath string `json:"outputPath"`
Success bool `json:"success"` Success bool `json:"success"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// ListVideos 列出文件夹中的视频文件 // ListVideos 列出文件夹中的视频文件
@ -54,7 +53,11 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
os.MkdirAll(tempDir, 0755) os.MkdirAll(tempDir, 0755)
// 获取视频信息 // 获取视频信息
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath) helper := GetFFmpegHelper()
if !helper.IsProbeAvailable() {
return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
}
cmd := helper.ProbeCommand("-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return fmt.Errorf("获取视频信息失败: %v", err) return fmt.Errorf("获取视频信息失败: %v", err)
@ -94,8 +97,11 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
// 如果是 HEVC先转换为 H.264 // 如果是 HEVC先转换为 H.264
if codecName == "hevc" { if codecName == "hevc" {
if !helper.IsAvailable() {
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
videoConvert := filepath.Join(tempDir, "convert.mp4") videoConvert := filepath.Join(tempDir, "convert.mp4")
cmd := exec.Command("ffmpeg", "-i", inputPath, "-c:v", "libx264", "-y", videoConvert) cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("转换HEVC失败: %v", err) return fmt.Errorf("转换HEVC失败: %v", err)
} }
@ -116,13 +122,13 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
videoPart2 := filepath.Join(tempDir, "part2.mp4") videoPart2 := filepath.Join(tempDir, "part2.mp4")
// 第一部分0 到 randomFrame - 0.016 // 第一部分0 到 randomFrame - 0.016
cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1) cmd = helper.Command("-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第一部分失败: %v", err) return fmt.Errorf("裁剪第一部分失败: %v", err)
} }
// 第二部分randomFrame 到结束 // 第二部分randomFrame 到结束
cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2) cmd = helper.Command("-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第二部分失败: %v", err) return fmt.Errorf("裁剪第二部分失败: %v", err)
} }
@ -137,7 +143,7 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/"))) file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/")))
file.Close() file.Close()
cmd = exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath) cmd = helper.Command("-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("合并视频失败: %v", err) return fmt.Errorf("合并视频失败: %v", err)
} }
@ -207,37 +213,37 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
// 检查文件是否已存在 // 检查文件是否已存在
if _, err := os.Stat(t.OutputPath); err == nil { if _, err := os.Stat(t.OutputPath); err == nil {
mu.Lock()
current++
result := ExtractFrameResult{
VideoPath: t.VideoPath,
OutputPath: t.OutputPath,
Success: true,
}
results = append(results, result)
mu.Unlock()
return
}
err := s.RemoveFrameRandom(ctx, t.VideoPath, t.OutputPath)
mu.Lock() mu.Lock()
current++ current++
result := ExtractFrameResult{ var result ExtractFrameResult
VideoPath: t.VideoPath, if err != nil {
OutputPath: t.OutputPath, result = ExtractFrameResult{
Success: true, VideoPath: t.VideoPath,
Success: false,
Error: err.Error(),
}
} else {
result = ExtractFrameResult{
VideoPath: t.VideoPath,
OutputPath: t.OutputPath,
Success: true,
}
} }
results = append(results, result) results = append(results, result)
mu.Unlock() mu.Unlock()
return
}
err := s.RemoveFrameRandom(ctx, t.VideoPath, t.OutputPath)
mu.Lock()
current++
var result ExtractFrameResult
if err != nil {
result = ExtractFrameResult{
VideoPath: t.VideoPath,
Success: false,
Error: err.Error(),
}
} else {
result = ExtractFrameResult{
VideoPath: t.VideoPath,
OutputPath: t.OutputPath,
Success: true,
}
}
results = append(results, result)
mu.Unlock()
}(task) }(task)
} }
@ -247,8 +253,12 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
// ModifyByMetadata 通过修改元数据改变文件 MD5 // ModifyByMetadata 通过修改元数据改变文件 MD5
func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error { func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error {
helper := GetFFmpegHelper()
if !helper.IsAvailable() {
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405")) comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
cmd := exec.Command("ffmpeg", "-i", inputPath, cmd := helper.Command("-i", inputPath,
"-c", "copy", "-c", "copy",
"-metadata", fmt.Sprintf("comment=%s", comment), "-metadata", fmt.Sprintf("comment=%s", comment),
"-y", outputPath) "-y", outputPath)
@ -291,29 +301,28 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath)) outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath))
outputPath := filepath.Join(outputDir, outputFileName) outputPath := filepath.Join(outputDir, outputFileName)
err := s.ModifyByMetadata(ctx, videoPath, outputPath) err := s.ModifyByMetadata(ctx, videoPath, outputPath)
mu.Lock() mu.Lock()
current++ current++
var result ExtractFrameResult var result ExtractFrameResult
if err != nil { if err != nil {
result = ExtractFrameResult{ result = ExtractFrameResult{
VideoPath: videoPath, VideoPath: videoPath,
Success: false, Success: false,
Error: err.Error(), Error: err.Error(),
}
} else {
result = ExtractFrameResult{
VideoPath: videoPath,
OutputPath: outputPath,
Success: true,
}
} }
} else { results = append(results, result)
result = ExtractFrameResult{ mu.Unlock()
VideoPath: videoPath,
OutputPath: outputPath,
Success: true,
}
}
results = append(results, result)
mu.Unlock()
}(video) }(video)
} }
wg.Wait() wg.Wait()
return results, nil return results, nil
} }

View File

@ -0,0 +1,383 @@
package services
import (
"embed"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// FFmpegHelper FFmpeg 工具助手
type FFmpegHelper struct {
ffmpegPath string
ffprobePath string
embeddedFS *embed.FS // 使用指针以便检查 nil
extractedPath string // 已提取的临时目录路径
}
var ffmpegHelper *FFmpegHelper
// InitFFmpegHelper 初始化 FFmpeg 助手(传入嵌入的文件系统)
func InitFFmpegHelper(embeddedFS embed.FS) {
if ffmpegHelper == nil {
ffmpegHelper = &FFmpegHelper{
embeddedFS: &embeddedFS,
}
ffmpegHelper.init()
}
}
// GetFFmpegHelper 获取 FFmpeg 助手实例(单例模式)
func GetFFmpegHelper() *FFmpegHelper {
if ffmpegHelper == nil {
// 如果没有初始化,创建一个不包含嵌入文件系统的实例
ffmpegHelper = &FFmpegHelper{}
ffmpegHelper.init()
}
return ffmpegHelper
}
// init 初始化 FFmpeg 路径
func (h *FFmpegHelper) init() {
// 先尝试从系统 PATH 查找
h.ffmpegPath = h.findExecutable("ffmpeg")
h.ffprobePath = h.findExecutable("ffprobe")
// 如果系统 PATH 中没有,尝试从嵌入的资源中提取
if h.ffmpegPath == "" && h.hasEmbeddedFS() {
h.ffmpegPath = h.extractEmbeddedBinary("ffmpeg")
}
if h.ffprobePath == "" && h.hasEmbeddedFS() {
h.ffprobePath = h.extractEmbeddedBinary("ffprobe")
}
// 如果嵌入的资源也没有,尝试从打包的资源目录中查找
if h.ffmpegPath == "" {
h.ffmpegPath = h.findBundledBinary("ffmpeg")
}
if h.ffprobePath == "" {
h.ffprobePath = h.findBundledBinary("ffprobe")
}
// 记录日志
if h.ffmpegPath != "" {
LogInfof("找到 ffmpeg: %s", h.ffmpegPath)
} else {
LogWarn("未找到 ffmpeg视频处理功能可能无法使用")
LogWarn("请确保 ffmpeg 已安装并在系统 PATH 中,或将其嵌入到 resources/ffmpeg/ 目录下")
}
if h.ffprobePath != "" {
LogInfof("找到 ffprobe: %s", h.ffprobePath)
} else {
LogWarn("未找到 ffprobe视频信息获取功能可能无法使用")
}
}
// findExecutable 从系统 PATH 中查找可执行文件
func (h *FFmpegHelper) findExecutable(name string) string {
path, err := exec.LookPath(name)
if err == nil {
// 验证文件是否可执行
if info, err := os.Stat(path); err == nil {
if runtime.GOOS != "windows" {
// Unix 系统检查执行权限
if info.Mode().Perm()&0111 != 0 {
return path
}
} else {
// Windows 系统直接返回
return path
}
}
}
return ""
}
// findBundledBinary 从打包的资源中查找二进制文件
func (h *FFmpegHelper) findBundledBinary(name string) string {
// 获取可执行文件所在目录
exePath, err := os.Executable()
if err != nil {
return ""
}
exeDir := filepath.Dir(exePath)
// 根据操作系统确定二进制文件名
binaryName := name
if runtime.GOOS == "windows" {
binaryName = name + ".exe"
}
// 可能的资源目录路径(按优先级排序)
possiblePaths := []string{
// macOS app bundle 中的路径
filepath.Join(exeDir, "..", "Resources", "ffmpeg", binaryName),
// Windows/Linux 相对路径
filepath.Join(exeDir, "resources", "ffmpeg", binaryName),
// 开发环境的相对路径
filepath.Join(exeDir, "..", "resources", "ffmpeg", binaryName),
// 当前目录
filepath.Join(".", "resources", "ffmpeg", binaryName),
// 直接在可执行文件目录
filepath.Join(exeDir, binaryName),
}
for _, path := range possiblePaths {
// 转换为绝对路径
absPath, err := filepath.Abs(path)
if err != nil {
continue
}
// 检查文件是否存在
if info, err := os.Stat(absPath); err == nil && !info.IsDir() {
// 在首次使用时,可能需要提取到临时目录并设置执行权限
if runtime.GOOS != "windows" {
// 确保文件有执行权限
os.Chmod(absPath, 0755)
}
return absPath
}
}
return ""
}
// GetFFmpegPath 获取 ffmpeg 路径
func (h *FFmpegHelper) GetFFmpegPath() string {
return h.ffmpegPath
}
// GetFFprobePath 获取 ffprobe 路径
func (h *FFmpegHelper) GetFFprobePath() string {
return h.ffprobePath
}
// Command 创建 FFmpeg 命令
func (h *FFmpegHelper) Command(args ...string) *exec.Cmd {
if h.ffmpegPath == "" {
return nil
}
return exec.Command(h.ffmpegPath, args...)
}
// ProbeCommand 创建 FFprobe 命令
func (h *FFmpegHelper) ProbeCommand(args ...string) *exec.Cmd {
if h.ffprobePath == "" {
return nil
}
return exec.Command(h.ffprobePath, args...)
}
// IsAvailable 检查 FFmpeg 是否可用
func (h *FFmpegHelper) IsAvailable() bool {
return h.ffmpegPath != ""
}
// IsProbeAvailable 检查 FFprobe 是否可用
func (h *FFmpegHelper) IsProbeAvailable() bool {
return h.ffprobePath != ""
}
// hasEmbeddedFS 检查是否有嵌入的文件系统
func (h *FFmpegHelper) hasEmbeddedFS() bool {
return h.embeddedFS != nil
}
// extractEmbeddedBinary 从嵌入的文件系统中提取二进制文件
func (h *FFmpegHelper) extractEmbeddedBinary(name string) string {
if !h.hasEmbeddedFS() {
return ""
}
// 确定二进制文件名(根据当前平台)
binaryName := name
if runtime.GOOS == "windows" {
binaryName = name + ".exe"
}
// 在嵌入的文件系统中查找文件
// 根据不同的嵌入方式,可能的路径有所不同:
// - 如果使用平台特定目录resources/ffmpeg/darwin/ffmpeg
// - 如果使用通用目录resources/ffmpeg/ffmpeg
possiblePaths := []string{
// 平台特定目录(推荐,用于分离打包)
fmt.Sprintf("resources/ffmpeg/%s/%s", runtime.GOOS, binaryName),
fmt.Sprintf("resources/ffmpeg/%s/%s", runtime.GOOS, name),
fmt.Sprintf("resources/ffmpeg/%s_%s/%s", runtime.GOOS, runtime.GOARCH, binaryName),
// 通用路径(向后兼容)
fmt.Sprintf("resources/ffmpeg/%s", binaryName),
fmt.Sprintf("resources/ffmpeg/%s", name),
}
var binaryData []byte
var err error
for _, path := range possiblePaths {
binaryData, err = h.embeddedFS.ReadFile(path)
if err == nil {
LogDebugf("从嵌入资源中找到二进制文件: %s", path)
break
}
}
if err != nil {
LogDebugf("未在嵌入资源中找到 %s尝试遍历目录", name)
// 如果直接路径找不到,尝试遍历目录查找
binaryData, _ = h.findBinaryInEmbeddedFS(name)
if binaryData == nil {
return ""
}
}
// 提取到临时目录
path, err := h.extractToTemp(name, binaryData)
if err != nil {
LogErrorf("提取嵌入的二进制文件失败: %v", err)
return ""
}
return path
}
// findBinaryInEmbeddedFS 在嵌入的文件系统中查找二进制文件
func (h *FFmpegHelper) findBinaryInEmbeddedFS(name string) ([]byte, string) {
if !h.hasEmbeddedFS() {
return nil, ""
}
binaryName := name
if runtime.GOOS == "windows" {
binaryName = name + ".exe"
}
// 使用结构体存储结果
type result struct {
data []byte
path string
}
var found *result
// 遍历 resources/ffmpeg 目录
err := fs.WalkDir(h.embeddedFS, "resources/ffmpeg", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // 忽略错误,继续遍历
}
// 检查文件名是否匹配
if !d.IsDir() && (d.Name() == name || d.Name() == binaryName) {
data, readErr := h.embeddedFS.ReadFile(path)
if readErr == nil {
// 找到匹配的文件
found = &result{
data: data,
path: path,
}
return fs.SkipAll // 找到后停止遍历
}
}
return nil
})
if err == nil && found != nil {
return found.data, found.path
}
return nil, ""
}
// extractToTemp 提取二进制文件到临时目录
func (h *FFmpegHelper) extractToTemp(name string, data []byte) (string, error) {
// 创建临时目录(只创建一次)
if h.extractedPath == "" {
tempDir := filepath.Join(os.TempDir(), "videoconcat-ffmpeg", fmt.Sprintf("%d", os.Getpid()))
if err := os.MkdirAll(tempDir, 0755); err != nil {
return "", fmt.Errorf("创建临时目录失败: %v", err)
}
h.extractedPath = tempDir
}
// 确定二进制文件名
binaryName := name
if runtime.GOOS == "windows" {
binaryName = name + ".exe"
}
// 目标路径
targetPath := filepath.Join(h.extractedPath, binaryName)
// 如果文件已存在且大小一致,直接返回
if info, err := os.Stat(targetPath); err == nil {
if info.Size() == int64(len(data)) {
return targetPath, nil
}
// 文件大小不一致,删除重新写入
os.Remove(targetPath)
}
// 写入文件
file, err := os.Create(targetPath)
if err != nil {
return "", fmt.Errorf("创建文件失败: %v", err)
}
defer file.Close()
if _, err := file.Write(data); err != nil {
return "", fmt.Errorf("写入文件失败: %v", err)
}
// 设置执行权限Unix 系统)
if runtime.GOOS != "windows" {
if err := os.Chmod(targetPath, 0755); err != nil {
return "", fmt.Errorf("设置执行权限失败: %v", err)
}
}
// 验证文件
if _, err := os.Stat(targetPath); err != nil {
return "", fmt.Errorf("验证文件失败: %v", err)
}
LogInfof("已提取嵌入的二进制文件到: %s", targetPath)
return targetPath, nil
}
// ExtractBundledBinary 从嵌入的资源中提取二进制文件到临时目录(兼容旧接口)
func (h *FFmpegHelper) ExtractBundledBinary(name string, data []byte) (string, error) {
return h.extractToTemp(name, data)
}
// CopyFile 复制文件(用于从嵌入资源复制到目标位置)
func (h *FFmpegHelper) CopyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("打开源文件失败: %v", err)
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("创建目标文件失败: %v", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return fmt.Errorf("复制文件失败: %v", err)
}
// 设置执行权限Unix 系统)
if runtime.GOOS != "windows" {
if err := os.Chmod(dst, 0755); err != nil {
return fmt.Errorf("设置执行权限失败: %v", err)
}
}
return nil
}

View File

@ -8,7 +8,6 @@ import (
"io" "io"
"math/rand" "math/rand"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@ -129,7 +128,11 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
} }
// 使用 FFmpeg 转换 // 使用 FFmpeg 转换
cmd := exec.Command("ffmpeg", "-i", videoPath, helper := GetFFmpegHelper()
if !helper.IsAvailable() {
return "", fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
cmd := helper.Command("-i", videoPath,
"-c:v", "libx264", "-c:v", "libx264",
"-c:a", "aac", "-c:a", "aac",
"-ar", "44100", "-ar", "44100",
@ -143,7 +146,7 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
// 尝试备用方案 // 尝试备用方案
cmd2 := exec.Command("ffmpeg", "-i", videoPath, cmd2 := helper.Command("-i", videoPath,
"-c", "copy", "-c", "copy",
"-f", "mpegts", "-f", "mpegts",
"-y", tsPath) "-y", tsPath)
@ -151,6 +154,7 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
if err2 != nil { if err2 != nil {
return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2)) return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2))
} }
// 备用方案成功,继续执行
} }
return tsPath, nil return tsPath, nil
@ -343,7 +347,17 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
"-y", outputPath, "-y", outputPath,
} }
cmd := exec.Command("ffmpeg", args...) helper := GetFFmpegHelper()
if !helper.IsAvailable() {
LogError("ffmpeg 不可用")
result.Status = "拼接失败"
result.Progress = "失败"
mu.Lock()
results = append(results, result)
mu.Unlock()
return
}
cmd := helper.Command(args...)
err = cmd.Run() err = cmd.Run()
if err != nil { if err != nil {
LogError(fmt.Sprintf("拼接视频失败: %v", err)) LogError(fmt.Sprintf("拼接视频失败: %v", err))
@ -366,7 +380,17 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
"-y", outputImgPath, "-y", outputImgPath,
} }
cmd := exec.Command("ffmpeg", args...) helper := GetFFmpegHelper()
if !helper.IsAvailable() {
LogError("ffmpeg 不可用")
result.Status = "拼接失败"
result.Progress = "失败"
mu.Lock()
results = append(results, result)
mu.Unlock()
return
}
cmd := helper.Command(args...)
cmd.Run() // 忽略错误,水印是可选的 cmd.Run() // 忽略错误,水印是可选的
} }
@ -383,12 +407,18 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
// 获取视频时长(使用 ffprobe // 获取视频时长(使用 ffprobe
seconds := 0 seconds := 0
cmd = exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath) if helper.IsProbeAvailable() {
output, err := cmd.Output() cmd = helper.ProbeCommand("-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath)
if err == nil { } else {
var duration float64 cmd = nil
fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration) }
seconds = int(duration) if cmd != nil {
output, err := cmd.Output()
if err == nil {
var duration float64
fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration)
seconds = int(duration)
}
} }
sizeMB := fileInfo.Size() / 1024 / 1024 sizeMB := fileInfo.Size() / 1024 / 1024
@ -408,4 +438,3 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
wg.Wait() wg.Wait()
return results, nil return results, nil
} }