VideoConcat/wails/services/extract_service.go
2026-01-10 19:00:52 +08:00

495 lines
15 KiB
Go
Raw Permalink 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)
}
// 过滤掉输出目录中的文件
var filteredMatches []string
outputDir := filepath.Join(folderPath, "out")
outputDirAlt := filepath.Join(folderPath, "output") // 兼容 output 目录
for _, match := range matches {
// 检查文件是否在输出目录中
absMatch, err := filepath.Abs(match)
if err != nil {
continue
}
absOutputDir, _ := filepath.Abs(outputDir)
absOutputDirAlt, _ := filepath.Abs(outputDirAlt)
// 如果文件不在输出目录中,则添加
if !strings.HasPrefix(absMatch, absOutputDir+string(filepath.Separator)) &&
!strings.HasPrefix(absMatch, absOutputDirAlt+string(filepath.Separator)) {
filteredMatches = append(filteredMatches, match)
}
}
LogDebugf("找到 %d 个视频文件(已排除输出目录中的 %d 个文件)", len(filteredMatches), len(matches)-len(filteredMatches))
return filteredMatches, nil
}
// RemoveFrameRandom 随机删除视频中的一帧
func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error {
LogDebugf("开始抽帧处理: %s -> %s", inputPath, outputPath)
// 检查输入文件是否存在
if _, err := os.Stat(inputPath); err != nil {
return fmt.Errorf("输入文件不存在: %v", err)
}
// 创建临时目录
tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano()))
defer os.RemoveAll(tempDir)
if err := os.MkdirAll(tempDir, 0755); err != nil {
return fmt.Errorf("创建临时目录失败: %v", err)
}
LogDebugf("临时目录已创建: %s", tempDir)
// 获取视频信息
helper := GetFFmpegHelper()
if !helper.IsProbeAvailable() {
return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
}
// 使用更详细的 ffprobe 命令获取视频信息
cmd := helper.ProbeCommand("-v", "error", "-show_entries",
"format=duration", "-show_entries", "stream=codec_name,r_frame_rate",
"-of", "default=noprint_wrappers=1:nokey=1", inputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffprobe 命令")
}
output, err := cmd.Output()
if err != nil {
// 如果上面的命令失败,尝试更简单的方式
LogWarnf("获取视频信息失败,尝试备用方法: %v, 输出: %s", err, string(output))
cmd = helper.ProbeCommand("-v", "error", "-show_format", "-show_streams", "-of", "json", inputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffprobe 命令")
}
output2, err2 := cmd.Output()
if err2 != nil {
return fmt.Errorf("获取视频信息失败: %v, 输出: %s", err2, string(output2))
}
// 如果备用方法也失败,返回错误
return fmt.Errorf("无法解析视频信息,可能文件已损坏或格式不支持。输出: %s", string(output2))
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var duration float64
var codecName string
var frameRate float64 = 30.0 // 默认帧率
// 解析 ffprobe 输出
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 尝试解析时长(可能是第一个数值行)
if duration == 0 {
var d float64
if n, _ := fmt.Sscanf(line, "%f", &d); n == 1 && d > 0 {
duration = d
LogDebugf("解析到视频时长: %.2f 秒", duration)
}
}
// 查找编解码器
if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") {
codecName = line
LogDebugf("检测到编解码器: %s", codecName)
}
// 解析帧率
if strings.Contains(line, "/") {
parts := strings.Split(line, "/")
if len(parts) == 2 {
var num, den float64
if n, _ := fmt.Sscanf(parts[0], "%f", &num); n == 1 {
if n, _ := fmt.Sscanf(parts[1], "%f", &den); n == 1 && den > 0 {
frameRate = num / den
LogDebugf("解析到帧率: %.2f fps", frameRate)
}
}
}
}
}
// 检查时长
if duration <= 0 {
LogErrorf("无法获取视频时长ffprobe 输出: %s", string(output))
return fmt.Errorf("无法获取视频时长(%.2f秒),文件可能已损坏或格式不支持", duration)
}
if duration < 20 {
return fmt.Errorf("视频时长太短(%.2f秒无法抽帧需要至少20秒", duration)
}
LogDebugf("视频信息: 时长=%.2f秒, 编解码器=%s, 帧率=%.2f", duration, codecName, frameRate)
// 如果是 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 cmd == nil {
return fmt.Errorf("无法创建 ffmpeg 命令")
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("转换HEVC失败: %v, 输出: %s", err, string(output))
}
inputPath = videoConvert
}
// 获取绝对路径Windows 兼容)
absInputPath, err := filepath.Abs(inputPath)
if err != nil {
absInputPath = inputPath
}
// 计算要删除的帧编号和时间范围
// 计算总帧数
totalFrames := int(duration * frameRate)
if totalFrames <= 0 {
totalFrames = int(duration * 30) // 默认30fps
}
// 随机选择要删除的帧(避开开头和结尾)
source := rand.NewSource(time.Now().UnixNano())
r := rand.New(source)
minFrame := int(frameRate * 20) // 至少20秒后的帧
maxFrame := totalFrames - int(frameRate*5) - 1 // 至少5秒前的帧
if maxFrame <= minFrame {
maxFrame = minFrame + 100 // 确保有足够的帧可选
}
randomFrameNum := minFrame + r.Intn(maxFrame-minFrame+1)
// 计算删除的时间范围删除一帧约0.016秒)
frameDuration := 1.0 / frameRate
deleteStartTime := float64(randomFrameNum) * frameDuration
deleteEndTime := deleteStartTime + frameDuration
LogDebugf("删除帧: 帧编号=%d, 时间范围=%.6f~%.6f秒, 总时长=%.2f秒",
randomFrameNum, deleteStartTime, deleteEndTime, duration)
// 使用 filter_complex 精确删除指定时间段
// 方法:使用 trim 和 concat filter 来删除指定时间段
// 视频部分:第一部分(0到deleteStartTime) + 第二部分(deleteEndTime到结束)
// 音频部分:第一部分(0到deleteStartTime) + 第二部分(deleteEndTime到结束)
filterComplex := fmt.Sprintf(
"[0:v]trim=start=0:end=%.6f,setpts=PTS-STARTPTS[v1];[0:v]trim=start=%.6f:end=%.6f,setpts=PTS-STARTPTS[v2];[v1][v2]concat=n=2:v=1:a=0[outv];[0:a]atrim=start=0:end=%.6f,asetpts=PTS-STARTPTS[a1];[0:a]atrim=start=%.6f:end=%.6f,asetpts=PTS-STARTPTS[a2];[a1][a2]concat=n=2:v=0:a=1[outa]",
deleteStartTime, deleteEndTime, duration, deleteStartTime, deleteEndTime, duration)
// 使用 filter_complex 一次性处理,确保时长精确
cmd = helper.Command("-i", absInputPath,
"-filter_complex", filterComplex,
"-map", "[outv]", "-map", "[outa]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-g", "30", "-keyint_min", "30", // 确保关键帧间隔,避免卡顿
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", "-y", outputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffmpeg 命令")
}
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("删除帧失败: %v, 输出: %s", err, string(output))
}
// 验证输出视频时长
if helper.IsProbeAvailable() {
cmd = helper.ProbeCommand("-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", outputPath)
if cmd != nil {
if probeOutput, err := cmd.Output(); err == nil {
var finalDuration float64
if n, _ := fmt.Sscanf(strings.TrimSpace(string(probeOutput)), "%f", &finalDuration); n == 1 {
expectedDuration := duration - frameDuration // 减去删除的一帧时间
diff := finalDuration - expectedDuration
if diff < -0.1 || diff > 0.1 { // 允许0.1秒的误差
LogWarnf("视频时长偏差较大: 期望约%.2f秒, 实际%.2f秒, 偏差%.2f秒",
expectedDuration, finalDuration, diff)
} else {
LogDebugf("视频时长验证: 实际%.2f秒 (期望约%.2f秒, 偏差%.3f秒)",
finalDuration, expectedDuration, diff)
}
}
}
}
}
// 验证输出文件
fileInfo, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("输出文件不存在: %v", err)
}
if fileInfo.Size() == 0 {
return fmt.Errorf("输出文件大小为0可能写入失败")
}
LogInfof("抽帧完成: %s (大小: %d 字节)", outputPath, fileInfo.Size())
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)
}
LogDebugf("输出目录已创建: %s", outputDir)
// 生成任务列表
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 {
LogErrorf("抽帧失败 [%d/%d]: %s, 错误: %v", current, len(tasks), t.VideoPath, err)
result = ExtractFrameResult{
VideoPath: t.VideoPath,
Success: false,
Error: err.Error(),
}
} else {
LogDebugf("抽帧成功 [%d/%d]: %s -> %s", current, len(tasks), t.VideoPath, t.OutputPath)
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")
}
// 获取绝对路径
absInputPath, err := filepath.Abs(inputPath)
if err != nil {
absInputPath = inputPath
}
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
// 首先尝试使用 -c copy流复制这样可以快速处理且不重新编码
cmd := helper.Command("-i", absInputPath,
"-c", "copy",
"-metadata", fmt.Sprintf("comment=%s", comment),
"-y", outputPath)
output, err := cmd.CombinedOutput()
if err != nil {
LogWarnf("使用流复制修改元数据失败,尝试重新编码: %v, 输出: %s", err, string(output))
// 如果流复制失败,尝试重新编码(使用高质量编码)
cmd = helper.Command("-i", absInputPath,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "18", // 高质量编码
"-c:a", "copy", // 音频流复制
"-metadata", fmt.Sprintf("comment=%s", comment),
"-movflags", "+faststart",
"-y", outputPath)
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("修改元数据失败: %v, 输出: %s", err, string(output))
}
}
// 验证输出文件是否存在
if _, err := os.Stat(outputPath); err != nil {
return fmt.Errorf("输出文件不存在: %v", err)
}
// 验证文件大小(确保文件已正确写入)
fileInfo, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("无法验证输出文件: %v", err)
}
if fileInfo.Size() == 0 {
return fmt.Errorf("输出文件大小为0可能写入失败")
}
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)
}
LogDebugf("输出目录已创建: %s", outputDir)
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 }()
// 使用纳秒时间戳作为随机种子,确保每个 goroutine 都有不同的随机数
source := rand.NewSource(time.Now().UnixNano())
r := rand.New(source)
randomNum := r.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 {
LogErrorf("修改元数据失败 [%d/%d]: %s, 错误: %v", current, len(videos), videoPath, err)
result = ExtractFrameResult{
VideoPath: videoPath,
Success: false,
Error: err.Error(),
}
} else {
LogDebugf("修改元数据成功 [%d/%d]: %s -> %s", current, len(videos), videoPath, outputPath)
result = ExtractFrameResult{
VideoPath: videoPath,
OutputPath: outputPath,
Success: true,
}
}
results = append(results, result)
mu.Unlock()
}(video)
}
wg.Wait()
return results, nil
}