384 lines
9.8 KiB
Go
384 lines
9.8 KiB
Go
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
|
||
}
|