VideoConcat/wails/services/extract_service.go
2026-01-08 13:25:06 +08:00

330 lines
8.8 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"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// ExtractService 抽帧服务
type ExtractService struct {
mu sync.Mutex
}
// NewExtractService 创建抽帧服务实例
func NewExtractService() *ExtractService {
return &ExtractService{}
}
// ExtractFrameRequest 抽帧请求
type ExtractFrameRequest struct {
FolderPath string `json:"folderPath"`
ExtractCount int `json:"extractCount"` // 每个视频生成的数量
}
// ExtractFrameResult 抽帧结果
type ExtractFrameResult struct {
VideoPath string `json:"videoPath"`
OutputPath string `json:"outputPath"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ListVideos 列出文件夹中的视频文件
func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) {
pattern := filepath.Join(folderPath, "*.mp4")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("查找视频文件失败: %v", err)
}
return matches, nil
}
// RemoveFrameRandom 随机删除视频中的一帧
func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error {
// 创建临时目录
tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano()))
defer os.RemoveAll(tempDir)
os.MkdirAll(tempDir, 0755)
// 获取视频信息
helper := GetFFmpegHelper()
if !helper.IsProbeAvailable() {
return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
}
cmd := helper.ProbeCommand("-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("获取视频信息失败: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var duration float64
var codecName string
var frameRate float64 = 30.0 // 默认帧率
for i, line := range lines {
line = strings.TrimSpace(line)
if i == 0 && line != "" {
fmt.Sscanf(line, "%f", &duration)
}
if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") {
codecName = line
}
if strings.Contains(line, "/") {
parts := strings.Split(line, "/")
if len(parts) == 2 {
var num, den float64
fmt.Sscanf(parts[0], "%f", &num)
fmt.Sscanf(parts[1], "%f", &den)
if den > 0 {
frameRate = num / den
_ = frameRate // 避免未使用变量警告
}
}
}
}
// 检查时长
if duration < 20 {
return fmt.Errorf("视频时长太短(%.2f秒无法抽帧需要至少20秒", duration)
}
// 如果是 HEVC先转换为 H.264
if codecName == "hevc" {
if !helper.IsAvailable() {
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
videoConvert := filepath.Join(tempDir, "convert.mp4")
cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
if err := cmd.Run(); err != nil {
return fmt.Errorf("转换HEVC失败: %v", err)
}
inputPath = videoConvert
}
// 随机选择要删除的帧时间点(避开开头和结尾)
rand.Seed(time.Now().UnixNano())
minFrameTime := 20.0
maxFrameTime := duration - 5.0
if maxFrameTime <= minFrameTime {
maxFrameTime = minFrameTime + 1.0
}
randomFrame := minFrameTime + rand.Float64()*(maxFrameTime-minFrameTime)
// 分割视频
videoPart1 := filepath.Join(tempDir, "part1.mp4")
videoPart2 := filepath.Join(tempDir, "part2.mp4")
// 第一部分0 到 randomFrame - 0.016
cmd = helper.Command("-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第一部分失败: %v", err)
}
// 第二部分randomFrame 到结束
cmd = helper.Command("-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第二部分失败: %v", err)
}
// 合并视频
concatFile := filepath.Join(tempDir, "concat.txt")
file, err := os.Create(concatFile)
if err != nil {
return fmt.Errorf("创建concat文件失败: %v", err)
}
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart1, "\\", "/")))
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/")))
file.Close()
cmd = helper.Command("-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("合并视频失败: %v", err)
}
// 验证输出文件
if _, err := os.Stat(outputPath); err != nil {
return fmt.Errorf("输出文件不存在: %v", err)
}
return nil
}
// ExtractFrames 批量抽帧
func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequest) ([]ExtractFrameResult, error) {
var results []ExtractFrameResult
// 列出所有视频
videos, err := s.ListVideos(ctx, req.FolderPath)
if err != nil {
return nil, err
}
if len(videos) == 0 {
return nil, fmt.Errorf("没有找到视频文件")
}
// 创建输出目录
outputDir := filepath.Join(req.FolderPath, "out")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("创建输出目录失败: %v", err)
}
// 生成任务列表
type Task struct {
VideoPath string
Index int
OutputPath string
}
var tasks []Task
for _, video := range videos {
originalFileName := strings.TrimSuffix(filepath.Base(video), filepath.Ext(video))
extension := filepath.Ext(video)
for i := 1; i <= req.ExtractCount; i++ {
outputFileName := fmt.Sprintf("%s_%04d%s", originalFileName, i, extension)
outputPath := filepath.Join(outputDir, outputFileName)
tasks = append(tasks, Task{
VideoPath: video,
Index: i,
OutputPath: outputPath,
})
}
}
// 并发处理(限制并发数)
semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup
// total := len(tasks) // 未使用
current := 0
var mu sync.Mutex
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
// 检查文件是否已存在
if _, err := os.Stat(t.OutputPath); err == nil {
mu.Lock()
current++
result := ExtractFrameResult{
VideoPath: t.VideoPath,
OutputPath: t.OutputPath,
Success: true,
}
results = append(results, result)
mu.Unlock()
return
}
err := s.RemoveFrameRandom(ctx, t.VideoPath, t.OutputPath)
mu.Lock()
current++
var result ExtractFrameResult
if err != nil {
result = ExtractFrameResult{
VideoPath: t.VideoPath,
Success: false,
Error: err.Error(),
}
} else {
result = ExtractFrameResult{
VideoPath: t.VideoPath,
OutputPath: t.OutputPath,
Success: true,
}
}
results = append(results, result)
mu.Unlock()
}(task)
}
wg.Wait()
return results, nil
}
// ModifyByMetadata 通过修改元数据改变文件 MD5
func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error {
helper := GetFFmpegHelper()
if !helper.IsAvailable() {
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
}
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
cmd := helper.Command("-i", inputPath,
"-c", "copy",
"-metadata", fmt.Sprintf("comment=%s", comment),
"-y", outputPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("修改元数据失败: %v", err)
}
return nil
}
// ModifyVideosMetadata 批量修改视频元数据
func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath string) ([]ExtractFrameResult, error) {
var results []ExtractFrameResult
videos, err := s.ListVideos(ctx, folderPath)
if err != nil {
return nil, err
}
outputDir := filepath.Join(folderPath, "out")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("创建输出目录失败: %v", err)
}
semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup
// total := len(videos) // 未使用
current := 0
var mu sync.Mutex
for _, video := range videos {
wg.Add(1)
go func(videoPath string) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
rand.Seed(time.Now().UnixNano())
randomNum := rand.Intn(90000) + 10000
outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath))
outputPath := filepath.Join(outputDir, outputFileName)
err := s.ModifyByMetadata(ctx, videoPath, outputPath)
mu.Lock()
current++
var result ExtractFrameResult
if err != nil {
result = ExtractFrameResult{
VideoPath: videoPath,
Success: false,
Error: err.Error(),
}
} else {
result = ExtractFrameResult{
VideoPath: videoPath,
OutputPath: outputPath,
Success: true,
}
}
results = append(results, result)
mu.Unlock()
}(video)
}
wg.Wait()
return results, nil
}