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 }