package video import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "time" dao "media/dao/video" dto "media/model/dto/video" entity "media/model/entity/video" "gitea.redpowerfuture.com/red-future/common/beans" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/guid" ) // SceneSplit 场景分割服务单例 var SceneSplit = new(sceneSplitService) type sceneSplitService struct{} // ---------- 异步任务管理 ---------- // CreateAsyncTask 创建场景分割异步任务,返回 taskID func (s *sceneSplitService) CreateAsyncTask(ctx context.Context, videoURL string, threshold float64, callbackURL string) (string, error) { if threshold <= 0 { threshold = 27.0 } taskID := "scene_" + guid.S() task := &entity.SceneSplitTask{ TaskID: taskID, VideoURL: videoURL, Status: "pending", CallbackURL: callbackURL, } if _, err := dao.SceneSplitTask.Insert(ctx, task); err != nil { return "", fmt.Errorf("创建任务失败: %v", err) } user := getUserFromCtx(ctx) g.Log().Infof(ctx, "[场景分割-异步] 创建任务 %s, videoUrl=%s, threshold=%.1f, callback=%s", taskID, videoURL, threshold, callbackURL) go s.processTask(user, taskID, videoURL, threshold, callbackURL) return taskID, nil } // processTask 后台处理场景分割任务 func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL string, threshold float64, callbackURL string) { bgCtx := context.Background() bgCtx = context.WithValue(bgCtx, "user", user) dao.SceneSplitTask.UpdateRunning(bgCtx, taskID) defer func() { if r := recover(); r != nil { errMsg := fmt.Sprintf("场景分割异常: %v", r) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) s.callback(bgCtx, taskID, callbackURL) } }() // 1. 创建临时工作目录 tempDir := g.Cfg().MustGet(bgCtx, "ffmpeg.temp_dir", "resource/temp").String() workDir := filepath.Join(tempDir, fmt.Sprintf("scene_%s", taskID)) os.MkdirAll(workDir, 0755) defer os.RemoveAll(workDir) // 2. 下载视频 g.Log().Infof(bgCtx, "[场景分割 %s] 开始下载视频: %s", taskID, videoURL) videoPath, dlErr := downloadFile(bgCtx, videoURL, workDir) if dlErr != nil { errMsg := fmt.Sprintf("视频下载失败: %v", dlErr) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } g.Log().Infof(bgCtx, "[场景分割 %s] 视频下载完成: %s", taskID, videoPath) // 获取视频总时长 videoDuration := getVideoDurationSeconds(bgCtx, videoPath) g.Log().Infof(bgCtx, "[场景分割 %s] 视频总时长: %.2f秒", taskID, videoDuration) // 3. 提取音频(在切片之前,保留完整音频) g.Log().Infof(bgCtx, "[场景分割 %s] 开始提取音频", taskID) audioPath := filepath.Join(workDir, fmt.Sprintf("audio_%s.m4a", taskID)) if err := s.extractAudio(bgCtx, videoPath, audioPath); err != nil { g.Log().Warningf(bgCtx, "[场景分割 %s] 音频提取失败: %v", taskID, err) audioPath = "" } // 4. 调用 PySceneDetect 检测场景 g.Log().Infof(bgCtx, "[场景分割 %s] 开始场景检测, threshold=%.1f", taskID, threshold) scenesJSONPath := filepath.Join(workDir, "scenes.json") if err := s.detectScenes(bgCtx, videoPath, scenesJSONPath, threshold); err != nil { errMsg := fmt.Sprintf("场景检测失败: %v", err) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } // 5. 解析场景时间线 scenes, parseErr := s.parseScenes(scenesJSONPath) if parseErr != nil { errMsg := fmt.Sprintf("解析场景结果失败: %v", parseErr) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } g.Log().Infof(bgCtx, "[场景分割 %s] 检测到 %d 个场景", taskID, len(scenes)) if len(scenes) == 0 { errMsg := "未检测到任何场景" g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } // 6. 按场景分割视频 g.Log().Infof(bgCtx, "[场景分割 %s] 开始分割视频 (%d 个分片)", taskID, len(scenes)) results, splitErr := s.splitVideo(bgCtx, videoPath, scenes, workDir, taskID) if splitErr != nil { errMsg := fmt.Sprintf("视频分割失败: %v", splitErr) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } g.Log().Infof(bgCtx, "[场景分割 %s] 视频分割完成, 共 %d 个分片文件", taskID, len(results)) // 7. 上传分片到 MinIO,构建有序分片列表 g.Log().Infof(bgCtx, "[场景分割 %s] 开始上传 %d 个分片到 MinIO", taskID, len(results)) uploadCtx := context.WithValue(context.Background(), "user", user) segments := make([]dto.SegmentEntry, len(results)) for i, r := range results { uploadRes, uploadErr := uploadToMinIO(uploadCtx, r.Path) if uploadErr != nil { errMsg := fmt.Sprintf("上传分片%d(%s)到MinIO失败: %v", i+1, r.Timeline, uploadErr) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg) if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err) } s.callback(bgCtx, taskID, callbackURL) return } segments[i] = dto.SegmentEntry{ Timeline: r.Timeline, URL: uploadRes.FileAddressPrefix + uploadRes.FileURL, } g.Log().Infof(bgCtx, "[场景分割 %s] 分片 %d/%d [%s] 上传完成: %s", taskID, i+1, len(results), r.Timeline, uploadRes.FileURL) } // 8. 上传音频到 MinIO audioURL := "" audioDuration := 0.0 if audioPath != "" { audioDuration = getVideoDurationSeconds(bgCtx, audioPath) uploadRes, uploadErr := uploadToMinIO(uploadCtx, audioPath) if uploadErr != nil { g.Log().Warningf(bgCtx, "[场景分割 %s] 音频上传失败: %v", taskID, uploadErr) } else { audioURL = uploadRes.FileAddressPrefix + uploadRes.FileURL g.Log().Infof(bgCtx, "[场景分割 %s] 音频上传完成: %s", taskID, audioURL) } } // 9. 更新数据库为成功 segmentsJSON, je := json.Marshal(segments) if je != nil { g.Log().Errorf(bgCtx, "[场景分割 %s] 序列化分片信息失败: %v", taskID, je) } else { dao.SceneSplitTask.UpdateSuccess(bgCtx, taskID, string(segmentsJSON), audioURL, len(segments), audioDuration, videoDuration) } g.Log().Infof(bgCtx, "[场景分割 %s] 完成! 分片数=%d, 音频=%s", taskID, len(segments), audioURL) if callbackURL != "" { s.callback(bgCtx, taskID, callbackURL) } } // detectScenes 调用 Python 脚本进行场景检测 func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJSON string, threshold float64) error { // 查找 Python 可执行文件 pythonPath, err := exec.LookPath("python3") if err != nil { pythonPath, err = exec.LookPath("python") if err != nil { return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]") } } // 脚本路径:相对于服务运行目录的 scripts/scene_detect.py scriptPath := "scripts/scene_detect.py" if _, err := os.Stat(scriptPath); os.IsNotExist(err) { // 尝试绝对路径 if absPath, absErr := filepath.Abs(scriptPath); absErr == nil { scriptPath = absPath } } ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() args := []string{ scriptPath, "--input", videoPath, "--output", outputJSON, "--threshold", fmt.Sprintf("%.1f", threshold), } g.Log().Infof(ctx, "[场景检测] 执行命令: %s %v", pythonPath, args) cmd := exec.CommandContext(ctxWithTimeout, pythonPath, args...) output, err := cmd.CombinedOutput() g.Log().Infof(ctx, "[场景检测] 输出: %s", string(output)) if err != nil { return fmt.Errorf("Python 场景检测失败: %v\n%s", err, string(output)) } return nil } // getVideoDurationSeconds 使用 ffprobe 获取音视频时长(秒) func getVideoDurationSeconds(ctx context.Context, videoPath string) float64 { ffprobePath, err := exec.LookPath("ffprobe") if err != nil { g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长") return 0 } args := []string{"-v", "quiet", "-print_format", "json", "-show_format", videoPath} cmd := exec.CommandContext(ctx, ffprobePath, args...) output, err := cmd.Output() if err != nil { return 0 } var info struct { Format struct { Duration string `json:"duration"` } `json:"format"` } if err := json.Unmarshal(output, &info); err != nil { return 0 } var secs float64 fmt.Sscanf(info.Format.Duration, "%f", &secs) return secs } // SceneBoundary 场景时间边界 type SceneBoundary struct { StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` } // parseScenes 解析场景检测结果 JSON 文件 func (s *sceneSplitService) parseScenes(jsonPath string) ([]SceneBoundary, error) { data, err := os.ReadFile(jsonPath) if err != nil { return nil, fmt.Errorf("读取场景结果文件失败: %v", err) } var scenes []SceneBoundary if err := json.Unmarshal(data, &scenes); err != nil { return nil, fmt.Errorf("解析场景 JSON 失败: %v", err) } return scenes, nil } // segmentResult 单个分片切割结果 type segmentResult struct { Path string // 本地文件路径 Timeline string // 时间线标识,如 "0.0-5.2" } // splitVideo 使用 FFmpeg 按场景时间线分割视频,返回分片文件路径+时间线 func (s *sceneSplitService) splitVideo(ctx context.Context, videoPath string, scenes []SceneBoundary, outputDir, taskID string) ([]segmentResult, error) { ffmpegPath, err := lookupFFmpegPath() if err != nil { return nil, err } var results []segmentResult for i, scene := range scenes { startTime := scene.StartTime duration := scene.EndTime - scene.StartTime if duration <= 0.1 { g.Log().Warningf(ctx, "[场景分割] 跳过过短的场景 %d (%.2fs-%.2fs, 时长%.2fs)", i+1, startTime, scene.EndTime, duration) continue } outputPath := filepath.Join(outputDir, fmt.Sprintf("segment_%03d_%s.mp4", i+1, taskID)) timelineKey := fmt.Sprintf("%.1f-%.1f", startTime, scene.EndTime) // 重编码模式:帧级精确切割,无重叠无间隙 args := []string{ "-ss", fmt.Sprintf("%.3f", startTime), "-i", videoPath, "-to", fmt.Sprintf("%.3f", duration), "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-c:a", "aac", "-b:a", "128k", "-avoid_negative_ts", "make_zero", "-y", outputPath, } g.Log().Infof(ctx, "[场景分割] 切割分片 %d/%d: %.2fs-%.2fs (时长%.2fs)", i+1, len(scenes), startTime, scene.EndTime, duration) cmd := exec.CommandContext(ctx, ffmpegPath, args...) output, cmdErr := cmd.CombinedOutput() if cmdErr != nil { return results, fmt.Errorf("分片%d切割失败: %v\n%s", i+1, cmdErr, string(output)) } results = append(results, segmentResult{ Path: outputPath, Timeline: timelineKey, }) } if len(results) == 0 { return nil, fmt.Errorf("所有分片切割后均为空") } return results, nil } // extractAudio 从视频中提取音频 func (s *sceneSplitService) extractAudio(ctx context.Context, videoPath, audioOutputPath string) error { ffmpegPath, err := lookupFFmpegPath() if err != nil { return err } // 提取音频并编码为 AAC args := []string{ "-i", videoPath, "-vn", // 不要视频 "-acodec", "aac", // AAC 编码 "-b:a", "128k", // 音频比特率 "-y", audioOutputPath, } cmd := exec.CommandContext(ctx, ffmpegPath, args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("FFmpeg 音频提取失败: %v\n%s", err, string(output)) } // 检查输出文件 if stat, statErr := os.Stat(audioOutputPath); statErr != nil || stat.Size() == 0 { return fmt.Errorf("音频提取失败: 输出文件为空或不存在") } return nil } // ---------- 查询任务 ---------- // GetTaskResult 查询场景分割任务结果 func (s *sceneSplitService) GetTaskResult(ctx context.Context, taskID string) (*dto.GetSceneSplitTaskRes, error) { task, err := dao.SceneSplitTask.GetByTaskID(ctx, taskID) if err != nil { return nil, fmt.Errorf("查询任务失败: %v", err) } if task == nil { return nil, fmt.Errorf("任务不存在: %s", taskID) } return dao.EntityToSceneSplitTaskRes(task), nil } // ---------- 回调通知 ---------- func (s *sceneSplitService) callback(ctx context.Context, taskID, callbackURL string) { if callbackURL == "" { return } task, err := dao.SceneSplitTask.GetByTaskID(ctx, taskID) if err != nil || task == nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 查询任务失败: %v", taskID, err) return } payload := map[string]interface{}{ "taskId": taskID, "status": task.Status, } if task.Status == "success" { payload["audioUrl"] = task.AudioURL payload["segments"] = dao.ParseSegmentEntries(task.SegmentURLs) payload["sceneCount"] = task.SceneCount payload["audioDuration"] = task.AudioDuration payload["videoDuration"] = task.VideoDuration } if task.Status == "failed" { payload["errorMessage"] = task.ErrorMessage } body, err := json.Marshal(payload) if err != nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 序列化payload失败: %v", taskID, err) return } cbUser := getUserFromCtx(ctx) userJSON, je := json.Marshal(cbUser) if je != nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 序列化用户信息失败: %v", taskID, je) return } g.Log().Infof(ctx, "[场景分割回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[场景分割回调 %s] curl: curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'", taskID, callbackURL, string(userJSON), string(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) if reqErr != nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 创建请求失败: %v", taskID, reqErr) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-User-Info", string(userJSON)) client := &http.Client{Timeout: 2 * time.Minute} resp, reqErr := client.Do(req) if reqErr != nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 请求失败: %v", taskID, reqErr) return } defer resp.Body.Close() respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { g.Log().Errorf(ctx, "[场景分割回调 %s] 读取响应失败: %v", taskID, readErr) return } g.Log().Infof(ctx, "[场景分割回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) }