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 }