Files
media/service/video/scene_split_service.go
T
2026-06-22 09:18:45 +08:00

434 lines
14 KiB
Go

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)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
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)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL)
return
}
// 5. 解析场景时间线
scenes, parseErr := s.parseScenes(scenesJSONPath)
if parseErr != nil {
errMsg := fmt.Sprintf("解析场景结果失败: %v", parseErr)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL)
return
}
g.Log().Infof(bgCtx, "[场景分割 %s] 检测到 %d 个场景", taskID, len(scenes))
if len(scenes) == 0 {
errMsg := "未检测到任何场景"
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
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)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
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)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg)
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, _ := json.Marshal(segments)
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, _ := json.Marshal(payload)
cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser)
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, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
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, _ := io.ReadAll(resp.Body)
g.Log().Infof(ctx, "[场景分割回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
}