434 lines
14 KiB
Go
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))
|
|
}
|