代码初始化
This commit is contained in:
285
service/video/concat_service.go
Normal file
285
service/video/concat_service.go
Normal file
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user