412 lines
10 KiB
Go
412 lines
10 KiB
Go
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 nil, 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 nil, fmt.Errorf("没有视频文件")
|
||
}
|
||
count := len(videoLists[0])
|
||
for i := 1; i < len(videoLists); i++ {
|
||
if len(videoLists[i]) != count {
|
||
return nil, 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)
|
||
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 {
|
||
result.Status = "拼接失败"
|
||
result.Progress = "失败"
|
||
mu.Lock()
|
||
results = append(results, result)
|
||
mu.Unlock()
|
||
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
|
||
}
|
||
|