Files
media/service/video/scene_split_service.go
T
2026-08-13 15:10:26 +08:00

577 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package video
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"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 可执行文件
// Windows 上 python3/python 别名可能指向 Microsoft Store 占位(假 Python),
// 需要绕过 WindowsApps 目录并检查常见安装路径
pythonPath, err := s.lookupPython(ctx)
if err != nil {
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]: %v", err)
}
// 脚本路径:相对于服务运行目录的 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
}
// lookupPython 查找真实可用的 Python 可执行文件
// 优先级:PATH 中的 python3/python(容器镜像内 venv 已置于 PATH 首位,命中带 scenedetect 的解释器)
// → 硬编码常见安装路径(Windows 用户/系统目录、/usr/bin/python3 等)
// 每个候选都必须同时通过「真实 Python」与「可导入 scenedetect」验证,两者缺一即跳过,
// 禁止只按 --version 选中解释器(否则会命中系统 python,其无 scenedetect,运行时报 ModuleNotFoundError)。
func (s *sceneSplitService) lookupPython(ctx context.Context) (string, error) {
for _, p := range s.collectPythonCandidates(ctx) {
if s.verifyPython(ctx, p) {
g.Log().Infof(ctx, "[场景检测] 使用 Python: %s", p)
return p, nil
}
}
return "", fmt.Errorf("未找到可用的 Python 3(需能导入 scenedetect,安装参考: pip install scenedetect[opencv,ffmpeg]")
}
// collectPythonCandidates 收集候选 Python 路径,PATH 优先,硬编码路径回退,去重
func (s *sceneSplitService) collectPythonCandidates(ctx context.Context) []string {
var candidates []string
seen := make(map[string]bool)
add := func(p string) {
if p != "" && !seen[p] {
seen[p] = true
candidates = append(candidates, p)
}
}
// 1. PATH 中的 python3 / python(容器镜像内 venv 已置于 PATH 首位)
for _, name := range []string{"python3", "python"} {
if path, err := exec.LookPath(name); err == nil {
// 跳过 Microsoft Store 假占位
if strings.Contains(path, "WindowsApps") {
g.Log().Warningf(ctx, "[场景检测] 跳过 %s(%s): Microsoft Store 占位,非真实 Python", name, path)
continue
}
add(path)
}
}
// 2. 常见安装路径(Windows/Linux/macOS),仅收集实际存在的
for _, p := range commonPythonPaths() {
if _, err := os.Stat(p); err == nil {
add(p)
}
}
return candidates
}
// commonPythonPaths 硬编码的常见 Python 安装路径,作为 PATH 查找的回退
func commonPythonPaths() []string {
return []string{
// 用户安装
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python314", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python311", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python310", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python39", "python.exe"),
// 系统安装
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python314", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python311", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python310", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python311", "python.exe"),
// C:\Python311 等根目录安装
filepath.Join("C:", "Python314", "python.exe"),
filepath.Join("C:", "Python313", "python.exe"),
filepath.Join("C:", "Python312", "python.exe"),
filepath.Join("C:", "Python311", "python.exe"),
filepath.Join("C:", "Python310", "python.exe"),
// WSL / Git Bash / Linux 环境
"/usr/bin/python3",
"/usr/bin/python",
}
}
// checkPythonImport 探测候选 Python 能否执行指定 Python 代码
func checkPythonImport(ctx context.Context, path, code string, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, path, "-c", code)
if err := cmd.Run(); err != nil {
g.Log().Warningf(ctx, "[场景检测] %s 执行 python -c %q 失败: %v", path, code, err)
return false
}
return true
}
// verifyPython 验证候选 Python:真实可用 且 能导入 scenedetect
func (s *sceneSplitService) verifyPython(ctx context.Context, path string) bool {
// 1. 确认是真实可用的 Python(排除 Microsoft Store 假占位等)
if !checkPythonImport(ctx, path, "import sys", 5*time.Second) {
return false
}
// 2. 确认能导入 scenedetect(场景分割实际依赖,缺失则运行时报 ModuleNotFoundError
if !checkPythonImport(ctx, path, "import scenedetect", 30*time.Second) {
g.Log().Warningf(ctx, "[场景检测] %s 无法导入 scenedetect,跳过", path)
return false
}
return true
}
// 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))
}