package video import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/gogf/gf/v2/frame/g" ) // ConcatService 视频拼接服务 type ConcatService struct{} // Concat 视频拼接服务单例 var Concat = new(ConcatService) // ConcatReq 视频拼接请求 type ConcatReq struct { VideoPaths []string // 视频文件路径列表(按此顺序拼接) OutputPath string // 输出视频文件路径,空则自动生成 Method string // 拼接方式: auto/fast/reencode,默认 auto } // ConcatRes 视频拼接响应 type ConcatRes struct { OutputPath string `json:"outputPath"` // 输出文件路径 FileSize int64 `json:"fileSize"` // 文件大小(bytes) Duration float64 `json:"duration"` // 拼接后总时长(秒) DurationStr string `json:"durationStr"` // 可读时长 MethodUsed string `json:"methodUsed"` // 实际使用的拼接方式 InputFiles int `json:"inputFiles"` // 输入文件数 } // Concat 拼接多个视频为一个 func (s *ConcatService) Concat(ctx context.Context, req *ConcatReq) (res *ConcatRes, err error) { if len(req.VideoPaths) < 2 { return nil, fmt.Errorf("至少需要2个视频才能拼接") } // 校验所有视频文件存在 for i, p := range req.VideoPaths { if _, err := os.Stat(p); os.IsNotExist(err) { return nil, fmt.Errorf("第%d个视频文件不存在: %s", i+1, p) } } ffmpegPath, err := s.getFFmpegPath() if err != nil { return nil, err } // 生成输出路径 outputPath := req.OutputPath if outputPath == "" { outputDir := filepath.Dir(req.VideoPaths[0]) outputPath = filepath.Join(outputDir, "concat_output.mp4") } method := req.Method if method == "" { method = "auto" } var methodUsed string switch method { case "fast": // 无损拼接(要求同编码参数,速度快但可能黑屏) err = s.concatByDemuxer(ctx, ffmpegPath, req.VideoPaths, outputPath) methodUsed = "concat demuxer (无损)" default: // 重编码拼接(自动归一化分辨率/音频,兼容所有视频) err = s.concatByFilter(ctx, ffmpegPath, req.VideoPaths, outputPath) methodUsed = "concat filter (重编码)" } if err != nil { return nil, fmt.Errorf("视频拼接失败: %v", err) } // 获取输出文件信息 stat, statErr := os.Stat(outputPath) if statErr != nil { return nil, fmt.Errorf("输出文件异常: %v", statErr) } // 获取时长 duration, _ := s.getVideoDuration(ctx, ffmpegPath, outputPath) res = &ConcatRes{ OutputPath: outputPath, FileSize: stat.Size(), Duration: duration, DurationStr: formatDuration(duration), MethodUsed: methodUsed, InputFiles: len(req.VideoPaths), } return } // concatByDemuxer 使用 concat demuxer 无损拼接(要求同编码参数) func (s *ConcatService) concatByDemuxer(ctx context.Context, ffmpegPath string, inputs []string, output string) error { // 创建文件列表 fileListPath := filepath.Join(filepath.Dir(output), "concat_list.txt") var lines []string for _, p := range inputs { lines = append(lines, fmt.Sprintf("file '%s'", p)) } if err := os.WriteFile(fileListPath, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { return fmt.Errorf("创建文件列表失败: %v", err) } defer os.Remove(fileListPath) args := []string{ "-f", "concat", "-safe", "0", "-i", fileListPath, "-c", "copy", // 直接复制流,不重编码 "-y", output, } cmd := exec.CommandContext(ctx, ffmpegPath, args...) outputBytes, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg demuxer 失败: %v\n%s", err, string(outputBytes)) } return nil } // concatByFilter 使用 concat filter 重编码拼接(自动归一化分辨率/音频参数) func (s *ConcatService) concatByFilter(ctx context.Context, ffmpegPath string, inputs []string, output string) error { n := len(inputs) // 1. 获取所有视频的分辨率,确定统一输出尺寸 maxW, maxH := 0, 0 var inputMeta []struct{ w, h int } for _, p := range inputs { w, h, _ := s.getVideoResolution(ctx, ffmpegPath, p) inputMeta = append(inputMeta, struct{ w, h int }{w, h}) if w > maxW { maxW = w } if h > maxH { maxH = h } } // 保底 if maxW == 0 { maxW = 1920 } if maxH == 0 { maxH = 1080 } // 2. 构建输入参数 var inputArgs []string for _, p := range inputs { inputArgs = append(inputArgs, "-i", p) } // 3. 构建 filter_complex:每个视频 scale+pad 到统一尺寸,然后 concat var filterParts []string for i := 0; i < n; i++ { filterParts = append(filterParts, fmt.Sprintf( "[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v%d]", i, maxW, maxH, maxW, maxH, i, )) filterParts = append(filterParts, fmt.Sprintf( "[%d:a]aresample=44100[a%d]", i, i, )) } // 收集归一化后的流 var concatInputs []string for i := 0; i < n; i++ { concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i)) } filterStr := fmt.Sprintf("%s;%sconcat=n=%d:v=1:a=1[outv][outa]", strings.Join(filterParts, ";"), strings.Join(concatInputs, ""), n) outputDir := filepath.Dir(output) args := append(inputArgs, "-filter_complex", filterStr, "-map", "[outv]", "-map", "[outa]", "-preset", "fast", "-crf", "23", "-y", output, ) // 调试:记录完整命令 g.Log().Debugf(ctx, "concat filter 命令: %s %v", ffmpegPath, args) // 保存 filter graph 用于调试 filterFile := filepath.Join(outputDir, "concat_filter.txt") os.WriteFile(filterFile, []byte(filterStr), 0644) defer os.Remove(filterFile) cmd := exec.CommandContext(ctx, ffmpegPath, args...) outputBytes, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg filter 失败: %v\n日志:\n%s", err, string(outputBytes)) } return nil } // getVideoResolution 获取视频分辨率 func (s *ConcatService) getVideoResolution(ctx context.Context, ffmpegPath, videoPath string) (width, height int, err error) { ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { ffprobePath = "ffprobe" } cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", videoPath, ) output, err := cmd.Output() if err != nil { return 0, 0, err } fmt.Sscanf(strings.TrimSpace(string(output)), "%d,%d", &width, &height) return } // getVideoDuration 获取视频时长 func (s *ConcatService) getVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) { ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { ffprobePath = "ffprobe" } cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath, ) output, err := cmd.Output() if err != nil { return 0, err } var duration float64 fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration) return duration, nil } func (s *ConcatService) getFFmpegPath() (string, error) { ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() if ffmpegPath != "" { if _, err := os.Stat(ffmpegPath); err == nil { return ffmpegPath, nil } } path, err := exec.LookPath("ffmpeg") if err != nil { return "", fmt.Errorf("未找到 ffmpeg") } return path, nil } func formatDuration(seconds float64) string { h := int(seconds) / 3600 m := (int(seconds) % 3600) / 60 s := int(seconds) % 60 return fmt.Sprintf("%02d:%02d:%02d", h, m, s) } // CleanupConcat 清理输入视频文件 func CleanupConcat(paths []string) { for _, p := range paths { os.Remove(p) } }