VideoConcat/wails/services/video_service.go

441 lines
11 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 services
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"math/rand"
"os"
"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 转换
helper := GetFFmpegHelper()
if !helper.IsAvailable() {
return "", fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
cmd := helper.Command("-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 := helper.Command("-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,
}
helper := GetFFmpegHelper()
if !helper.IsAvailable() {
LogError("ffmpeg 不可用")
result.Status = "拼接失败"
result.Progress = "失败"
mu.Lock()
results = append(results, result)
mu.Unlock()
return
}
cmd := helper.Command(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,
}
helper := GetFFmpegHelper()
if !helper.IsAvailable() {
LogError("ffmpeg 不可用")
result.Status = "拼接失败"
result.Progress = "失败"
mu.Lock()
results = append(results, result)
mu.Unlock()
return
}
cmd := helper.Command(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
if helper.IsProbeAvailable() {
cmd = helper.ProbeCommand("-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath)
} else {
cmd = nil
}
if cmd != nil {
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
}