495 lines
15 KiB
Go
495 lines
15 KiB
Go
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
|
||
}
|