Files
media/service/video/concat_service.go
2026-05-19 14:33:06 +08:00

286 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}