package services import ( "context" "crypto/md5" "encoding/hex" "fmt" "io" "math/rand" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) // VideoService 视频处理服务 type VideoService struct { mu sync.Mutex } // NewVideoService 创建视频服务实例 func NewVideoService() *VideoService { return &VideoService{} } // FolderInfo 文件夹信息 type FolderInfo struct { Path string `json:"path"` Name string `json:"name"` VideoCount int `json:"videoCount"` VideoPaths []string `json:"videoPaths"` } // VideoConcatRequest 视频拼接请求 type VideoConcatRequest struct { FolderPath string `json:"folderPath"` Num int `json:"num"` JoinType int `json:"joinType"` // 1: 组合拼接, 2: 顺序拼接 AuditImagePath string `json:"auditImagePath"` FolderInfos []FolderInfo `json:"folderInfos"` } // VideoConcatResult 视频拼接结果 type VideoConcatResult struct { Index int `json:"index"` FileName string `json:"fileName"` FilePath string `json:"filePath"` Size string `json:"size"` Seconds int `json:"seconds"` Status string `json:"status"` Progress string `json:"progress"` } // ListFolders 列出文件夹中的视频文件夹 func (s *VideoService) ListFolders(ctx context.Context, folderPath string) ([]FolderInfo, error) { var folderInfos []FolderInfo dir, err := os.ReadDir(folderPath) if err != nil { return nil, fmt.Errorf("读取目录失败: %v", err) } for _, entry := range dir { if !entry.IsDir() || entry.Name() == "output" { continue } fullPath := filepath.Join(folderPath, entry.Name()) videoFiles, err := filepath.Glob(filepath.Join(fullPath, "*.mp4")) if err != nil { continue } folderInfos = append(folderInfos, FolderInfo{ Path: fullPath, Name: entry.Name(), VideoCount: len(videoFiles), VideoPaths: videoFiles, }) } return folderInfos, nil } // GetLargeFileMD5 计算大文件的 MD5 func (s *VideoService) GetLargeFileMD5(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", err } defer file.Close() hash := md5.New() buffer := make([]byte, 8192) // 8KB 缓冲区 for { n, err := file.Read(buffer) if n > 0 { hash.Write(buffer[:n]) } if err == io.EOF { break } if err != nil { return "", err } } return hex.EncodeToString(hash.Sum(nil)), nil } // ConvertVideoToTS 将视频转换为 TS 格式 func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) { // 计算 MD5 md5Hash, err := s.GetLargeFileMD5(videoPath) if err != nil { return "", fmt.Errorf("计算MD5失败: %v", err) } // 临时文件路径 tempDir := os.TempDir() tsPath := filepath.Join(tempDir, md5Hash+".ts") // 如果文件已存在,直接返回 if _, err := os.Stat(tsPath); err == nil { return tsPath, nil } // 使用 FFmpeg 转换 cmd := exec.Command("ffmpeg", "-i", videoPath, "-c:v", "libx264", "-c:a", "aac", "-ar", "44100", "-b:a", "128k", "-crf", "23", "-vf", "fps=30", "-movflags", "+faststart", "-f", "mpegts", "-y", tsPath) output, err := cmd.CombinedOutput() if err != nil { // 尝试备用方案 cmd2 := exec.Command("ffmpeg", "-i", videoPath, "-c", "copy", "-f", "mpegts", "-y", tsPath) output2, err2 := cmd2.CombinedOutput() if err2 != nil { return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2)) } } return tsPath, nil } // GenerateCombinations 生成所有视频组合(笛卡尔积) func (s *VideoService) GenerateCombinations(videoLists [][]string, index int, currentCombination []string, result *[][]string) { if index == len(videoLists) { combination := make([]string, len(currentCombination)) copy(combination, currentCombination) *result = append(*result, combination) return } for _, video := range videoLists[index] { currentCombination = append(currentCombination, video) s.GenerateCombinations(videoLists, index+1, currentCombination, result) currentCombination = currentCombination[:len(currentCombination)-1] } } // JoinVideos 拼接视频 // 注意:Wails3 中回调函数需要使用事件系统,这里先简化处理 func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) ([]VideoConcatResult, error) { var results []VideoConcatResult // 创建输出目录 outputDir := filepath.Join(req.FolderPath, "output") if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("创建输出目录失败: %v", err) } // 准备视频列表 var videoLists [][]string for _, folderInfo := range req.FolderInfos { videoLists = append(videoLists, folderInfo.VideoPaths) } var combinations [][]string if req.JoinType == 1 { // 组合拼接模式 s.GenerateCombinations(videoLists, 0, []string{}, &combinations) // 随机选择指定数量的组合 if req.Num < len(combinations) { rand.Seed(time.Now().UnixNano()) selected := make(map[int]bool) newCombinations := [][]string{} for len(newCombinations) < req.Num { idx := rand.Intn(len(combinations)) if !selected[idx] { selected[idx] = true newCombinations = append(newCombinations, combinations[idx]) } } combinations = newCombinations } } else if req.JoinType == 2 { // 顺序拼接模式 if len(videoLists) == 0 { return fmt.Errorf("没有视频文件") } count := len(videoLists[0]) for i := 1; i < len(videoLists); i++ { if len(videoLists[i]) != count { return fmt.Errorf("所有文件夹中的视频数量必须相同") } } for i := 0; i < count; i++ { combination := []string{} for _, videoList := range videoLists { combination = append(combination, videoList[i]) } combinations = append(combinations, combination) } } // 先转换所有视频为 TS 格式 var allVideoPaths []string for _, combination := range combinations { allVideoPaths = append(allVideoPaths, combination...) } // 去重 uniqueVideos := make(map[string]bool) var uniqueVideoPaths []string for _, path := range allVideoPaths { if !uniqueVideos[path] { uniqueVideos[path] = true uniqueVideoPaths = append(uniqueVideoPaths, path) } } // 并发转换视频(限制并发数) semaphore := make(chan struct{}, 10) var wg sync.WaitGroup tsMap := make(map[string]string) var mu sync.Mutex for _, videoPath := range uniqueVideoPaths { wg.Add(1) go func(path string) { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() tsPath, err := s.ConvertVideoToTS(path) if err != nil { LogError(fmt.Sprintf("转换视频失败: %v", err)) return } mu.Lock() tsMap[path] = tsPath mu.Unlock() }(videoPath) } wg.Wait() // 拼接视频 semaphore = make(chan struct{}, 10) var mu sync.Mutex for i, combination := range combinations { wg.Add(1) go func(index int, combo []string) { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() // 获取第一个视频的文件名 firstVideoName := strings.TrimSuffix(filepath.Base(combo[0]), filepath.Ext(combo[0])) outputFileName := fmt.Sprintf("%s_%04d.mp4", firstVideoName, index+1) outputPath := filepath.Join(outputDir, outputFileName) // 创建结果对象 result := VideoConcatResult{ Index: index + 1, FileName: outputFileName, FilePath: outputPath, Status: "处理中", Progress: "0%", } // 获取 TS 文件路径 var tsPaths []string for _, videoPath := range combo { if tsPath, ok := tsMap[videoPath]; ok { tsPaths = append(tsPaths, tsPath) } else { LogError(fmt.Sprintf("找不到视频的TS文件: %s", videoPath)) result.Status = "拼接失败" result.Progress = "失败" mu.Lock() results = append(results, result) mu.Unlock() return } } // 创建 concat 文件 concatFile := filepath.Join(os.TempDir(), fmt.Sprintf("concat_%d_%d.txt", time.Now().Unix(), index)) file, err := os.Create(concatFile) if err != nil { LogError(fmt.Sprintf("创建concat文件失败: %v", err)) result.Status = "拼接失败" result.Progress = "失败" mu.Lock() results = append(results, result) mu.Unlock() return } for _, tsPath := range tsPaths { file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(tsPath, "\\", "/"))) } file.Close() defer os.Remove(concatFile) // 拼接视频 args := []string{ "-f", "concat", "-safe", "0", "-i", concatFile, "-c:v", "copy", "-c:a", "aac", "-ar", "44100", "-movflags", "+faststart", "-analyzeduration", "100M", "-probesize", "100M", "-y", outputPath, } cmd := exec.Command("ffmpeg", args...) err = cmd.Run() if err != nil { LogError(fmt.Sprintf("拼接视频失败: %v", err)) result.Status = "拼接失败" result.Progress = "失败" mu.Lock() results = append(results, result) mu.Unlock() return } // 如果指定了审核图片,添加水印 if req.AuditImagePath != "" { outputImgPath := filepath.Join(outputDir, fmt.Sprintf("%s_%04d_img.mp4", firstVideoName, index+1)) args := []string{ "-i", outputPath, "-i", req.AuditImagePath, "-filter_complex", "[0:v][1:v] overlay=0:H-h", "-movflags", "+faststart", "-y", outputImgPath, } cmd := exec.Command("ffmpeg", args...) cmd.Run() // 忽略错误,水印是可选的 } // 获取文件信息 fileInfo, err := os.Stat(outputPath) if err != nil { progressCallback(VideoConcatResult{ Index: index + 1, FileName: outputFileName, Status: "拼接失败", Progress: "失败", }) return } // 获取视频时长(使用 ffprobe) seconds := 0 cmd = exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath) 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 result.FilePath = outputPath result.Size = fmt.Sprintf("%dMB", sizeMB) result.Seconds = seconds result.Status = "拼接成功" result.Progress = "100%" mu.Lock() results = append(results, result) mu.Unlock() }(i, combination) } wg.Wait() return results, nil }