From b740e400c9cf278b9370ad5e8064027d8e6f2ed7 Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Wed, 1 Jul 2026 16:52:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=8E=B7=E5=8F=96=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=97=B6=E9=95=BF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/video/video_duration_controller.go | 26 +++++ model/dto/video/video_duration_dto.go | 25 ++++ service/video/caption_service.go | 12 +- service/video/merge_service.go | 27 +++++ service/video/video_duration_service.go | 108 ++++++++++++++++++ 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 controller/video/video_duration_controller.go create mode 100644 model/dto/video/video_duration_dto.go create mode 100644 service/video/video_duration_service.go diff --git a/controller/video/video_duration_controller.go b/controller/video/video_duration_controller.go new file mode 100644 index 0000000..1981e31 --- /dev/null +++ b/controller/video/video_duration_controller.go @@ -0,0 +1,26 @@ +package video + +import ( + "context" + + dto "media/model/dto/video" + service "media/service/video" + + "github.com/gogf/gf/v2/frame/g" +) + +// GetDuration 获取视频时长 POST /video/duration +// 方法挂载在 video struct 上,利用 Concat 已注册的 `/video` 组前缀 +func (c *video) GetDuration(ctx context.Context, req *dto.VideoDurationReq) (res *dto.VideoDurationRes, err error) { + ctx = withUser(ctx) + g.Log().Infof(ctx, "[视频时长] 收到请求: video_urls=%v", req.VideoURLs) + + res, err = service.VideoDuration.GetVideoDurations(ctx, req.VideoURLs) + if err != nil { + g.Log().Errorf(ctx, "[视频时长] 获取失败: %v", err) + return nil, err + } + + g.Log().Infof(ctx, "[视频时长] 响应: count=%d, total=%s", res.Count, res.TotalDurationStr) + return res, nil +} diff --git a/model/dto/video/video_duration_dto.go b/model/dto/video/video_duration_dto.go new file mode 100644 index 0000000..f17011a --- /dev/null +++ b/model/dto/video/video_duration_dto.go @@ -0,0 +1,25 @@ +package video + +import "github.com/gogf/gf/v2/frame/g" + +// VideoDurationReq 视频时长查询请求 +type VideoDurationReq struct { + g.Meta `path:"/duration" method:"post" tags:"视频时长" summary:"获取视频时长" dc:"传入一个或多个视频URL,返回每个视频的时长及总时长"` + VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"` +} + +// VideoInfo 单个视频时长信息 +type VideoInfo struct { + Index int `json:"index" dc:"序号"` + VideoURL string `json:"videoUrl" dc:"视频URL"` + Duration float64 `json:"duration" dc:"时长(秒)"` + DurationStr string `json:"durationStr" dc:"可读时长(HH:MM:SS)"` +} + +// VideoDurationRes 视频时长查询响应 +type VideoDurationRes struct { + Videos []VideoInfo `json:"videos" dc:"视频时长列表"` + Count int `json:"count" dc:"视频数量"` + TotalDuration float64 `json:"totalDuration" dc:"总时长(秒)"` + TotalDurationStr string `json:"totalDurationStr" dc:"可读总时长(HH:MM:SS)"` +} diff --git a/service/video/caption_service.go b/service/video/caption_service.go index ef5fdd2..983be2c 100644 --- a/service/video/caption_service.go +++ b/service/video/caption_service.go @@ -603,8 +603,8 @@ func (s *captionService) concatVideos(ctx context.Context, videoPaths []string, // getVideoRealDuration 用 ffprobe 获取视频时长(秒,float) func getVideoRealDuration(ctx context.Context, videoPath string) float64 { - ffprobePath, err := exec.LookPath("ffprobe") - if err != nil { + ffprobePath := lookupFFprobePath() + if ffprobePath == "" { g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长") return 0 } @@ -637,8 +637,8 @@ func getVideoRealDuration(ctx context.Context, videoPath string) float64 { // getVideoDurationStr 获取视频时长可读字符串 func getVideoDurationStr(ctx context.Context, videoPath string) string { - ffprobePath, err := exec.LookPath("ffprobe") - if err != nil { + ffprobePath := lookupFFprobePath() + if ffprobePath == "" { g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串") return "" } @@ -676,8 +676,8 @@ func getVideoDurationStr(ctx context.Context, videoPath string) string { // getVideoResolution 使用 ffprobe 获取视频分辨率 func getVideoResolution(ctx context.Context, videoPath string) (width, height int) { - ffprobePath, err := exec.LookPath("ffprobe") - if err != nil { + ffprobePath := lookupFFprobePath() + if ffprobePath == "" { g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920") return 1080, 1920 // 默认竖屏 } diff --git a/service/video/merge_service.go b/service/video/merge_service.go index 4b9d972..9d817f4 100644 --- a/service/video/merge_service.go +++ b/service/video/merge_service.go @@ -452,6 +452,33 @@ func cleanupFiles(paths []string) { } } +// lookupFFprobePath 查找 ffprobe 可执行文件路径 +// 优先查找 ffmpeg.path 配置同目录下的 ffprobe,回退到系统 PATH +func lookupFFprobePath() string { + ctx := context.Background() + ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String() + if ffmpegCfgPath != "" { + // 尝试在 ffmpeg 同目录下找 ffprobe + ffprobeCandidate := filepath.Join(filepath.Dir(ffmpegCfgPath), "ffprobe") + if _, err := os.Stat(ffprobeCandidate); err == nil { + g.Log().Infof(ctx, "[ffprobe] ✔ 使用 ffmpeg.path 同目录下的 ffprobe: %s", ffprobeCandidate) + return ffprobeCandidate + } + // Windows: 尝试加 .exe + ffprobeCandidateExe := ffprobeCandidate + ".exe" + if _, err := os.Stat(ffprobeCandidateExe); err == nil { + g.Log().Infof(ctx, "[ffprobe] ✔ 使用 ffmpeg.path 同目录下的 ffprobe.exe: %s", ffprobeCandidateExe) + return ffprobeCandidateExe + } + } + path, err := exec.LookPath("ffprobe") + if err == nil { + return path + } + g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到 ffprobe") + return "" +} + // lookupFFmpegPath 查找 ffmpeg 可执行文件路径 func lookupFFmpegPath() (string, error) { ctx := context.Background() diff --git a/service/video/video_duration_service.go b/service/video/video_duration_service.go new file mode 100644 index 0000000..c5644e0 --- /dev/null +++ b/service/video/video_duration_service.go @@ -0,0 +1,108 @@ +package video + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + dto "media/model/dto/video" + + "github.com/gogf/gf/v2/frame/g" +) + +type videoDurationService struct{} + +// VideoDuration 视频时长服务单例 +var VideoDuration = new(videoDurationService) + +// GetVideoDurations 并发获取多个视频的时长,返回每个视频的时长信息及总时长 +func (s *videoDurationService) GetVideoDurations(ctx context.Context, urls []string) (*dto.VideoDurationRes, error) { + if len(urls) == 0 { + return nil, fmt.Errorf("视频URL列表不能为空") + } + + tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String() + if tempDir == "" { + tempDir = "resource/temp" + } + if !filepath.IsAbs(tempDir) { + absDir, _ := filepath.Abs(tempDir) + tempDir = absDir + } + os.MkdirAll(tempDir, 0755) + + g.Log().Infof(ctx, "[视频时长] 开始获取 %d 个视频时长", len(urls)) + + type itemResult struct { + index int + url string + duration float64 + durationStr string + err error + } + + results := make([]itemResult, len(urls)) + sem := make(chan struct{}, 5) // 最大并发数 5 + var wg sync.WaitGroup + + for i, u := range urls { + wg.Add(1) + i, u := i, u + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + // 使用独立 context(带超时),不受请求 context 生命周期影响 + dlCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + localPath, dlErr := downloadFile(dlCtx, u, tempDir) + if dlErr != nil { + g.Log().Warningf(ctx, "[视频时长] 下载失败 index=%d, url=%s, err=%v", i+1, u, dlErr) + results[i] = itemResult{index: i, url: u, err: dlErr} + return + } + + duration := getVideoRealDuration(ctx, localPath) + durationStr := formatDuration(duration) + + os.Remove(localPath) + results[i] = itemResult{index: i, url: u, duration: duration, durationStr: durationStr} + }() + } + wg.Wait() + + var videos []dto.VideoInfo + var total float64 + for _, r := range results { + if r.err != nil { + continue + } + videos = append(videos, dto.VideoInfo{ + Index: r.index + 1, + VideoURL: r.url, + Duration: r.duration, + DurationStr: r.durationStr, + }) + total += r.duration + } + + if len(videos) == 0 { + return nil, fmt.Errorf("所有视频下载或时长获取失败") + } + + totalStr := formatDuration(total) + + g.Log().Infof(ctx, "[视频时长] 完成: 成功=%d, 总时长=%s", len(videos), totalStr) + + return &dto.VideoDurationRes{ + Videos: videos, + Count: len(videos), + TotalDuration: total, + TotalDurationStr: totalStr, + }, nil +}