Files
media/service/video/caption_service.go
2026-06-23 12:49:34 +08:00

840 lines
27 KiB
Go
Raw Permalink 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"
"html"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"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"
)
// Caption 字幕叠加服务单例
var Caption = new(captionService)
type captionService struct{}
// ---------- 异步任务管理 ----------
// CreateAsyncTask 创建字幕叠加任务,返回 taskID
func (s *captionService) CreateAsyncTask(ctx context.Context, videoURLs []string, audioURL string, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, elements []dto.CaptionElement, callbackURL string) (string, error) {
if len(videoURLs) < 1 {
return "", fmt.Errorf("至少需要1个视频")
}
if len(elements) < 1 && len(subtitles) < 1 {
return "", fmt.Errorf("至少需要字幕时间线或字幕元素")
}
if elements == nil {
elements = []dto.CaptionElement{}
}
if subtitles == nil {
subtitles = []dto.SubtitleSegment{}
}
videoURLsJSON, _ := json.Marshal(videoURLs)
elementsJSON, _ := json.Marshal(elements)
taskID := "cap_" + guid.S()
task := &entity.VideoCaptionTask{
TaskID: taskID,
VideoURLs: string(videoURLsJSON),
AudioURL: audioURL,
Elements: string(elementsJSON),
Status: "pending",
CallbackURL: callbackURL,
}
if _, err := dao.CaptionTask.Insert(ctx, task); err != nil {
return "", fmt.Errorf("创建任务失败: %v", err)
}
user := getUserFromCtx(ctx)
g.Log().Infof(ctx, "[字幕叠加-异步] 创建任务 %s, 视频数=%d, 字幕段数=%d, 元素数=%d, 回调=%s",
taskID, len(videoURLs), len(subtitles), len(elements), callbackURL)
go s.processTask(user, taskID, videoURLs, audioURL, subtitles, subtitleStyle, elements, callbackURL)
return taskID, nil
}
// processTask 后台处理字幕叠加任务
func (s *captionService) processTask(user *beans.User, taskID string, videoURLs []string, audioURL string, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, elements []dto.CaptionElement, callbackURL string) {
bgCtx := context.Background()
bgCtx = context.WithValue(bgCtx, "user", user)
dao.CaptionTask.UpdateRunning(bgCtx, taskID)
defer func() {
if r := recover(); r != nil {
errMsg := fmt.Sprintf("字幕叠加异常: %v", r)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL, nil)
}
}()
// 1. 创建临时工作目录
tempDir := g.Cfg().MustGet(bgCtx, "ffmpeg.temp_dir", "resource/temp").String()
projectDir := filepath.Join(tempDir, fmt.Sprintf("caption_%s", taskID))
os.RemoveAll(projectDir)
os.MkdirAll(projectDir, 0755)
defer os.RemoveAll(projectDir)
// 2. 下载所有视频
var videoPaths []string
for i, videoURL := range videoURLs {
savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr)
continue
}
videoPaths = append(videoPaths, savePath)
}
if len(videoPaths) < 1 {
errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs))
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
// 3. 从第一个视频自动检测分辨率
width, height := getVideoResolution(bgCtx, videoPaths[0])
g.Log().Infof(bgCtx, "[字幕 %s] 视频分辨率: %dx%d", taskID, width, height)
// 更新 DB 中的分辨率字段
dao.CaptionTask.UpdateResolution(bgCtx, taskID, width, height)
// 4. 如果有多段视频,先用 FFmpeg 拼接成一段
videoCount := len(videoPaths)
if videoCount > 1 {
concatedPath := filepath.Join(projectDir, "concated.mp4")
if err := s.concatVideos(bgCtx, videoPaths, concatedPath); err != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频拼接失败(使用首个视频): %v", taskID, err)
} else {
videoPaths = []string{concatedPath}
g.Log().Infof(bgCtx, "[字幕 %s] 视频拼接完成: %d段 → %s", taskID, videoCount, concatedPath)
}
}
// 4.5 消音处理:去掉原视频音频,避免与新音频混音
mutedPath := filepath.Join(projectDir, "muted.mp4")
if err := s.muteVideo(bgCtx, videoPaths[0], mutedPath); err != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频消音失败: %v", taskID, err)
} else {
videoPaths = []string{mutedPath}
g.Log().Infof(bgCtx, "[字幕 %s] 视频消音完成: %s", taskID, mutedPath)
}
// 5. 用 ffprobe 获取视频真实总时长
totalVideoDuration := getVideoRealDuration(bgCtx, videoPaths[0])
if totalVideoDuration <= 0 {
totalVideoDuration = 30
}
g.Log().Infof(bgCtx, "[字幕 %s] 视频总时长: %.2f 秒", taskID, totalVideoDuration)
// 6. 下载元素中的图片资源(type=image)
for i, elem := range elements {
if elem.Type == "image" && elem.ImageURL != "" {
savePath, dlErr := downloadFile(bgCtx, elem.ImageURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 图片%d下载失败 %s: %v", taskID, i, elem.ImageURL, dlErr)
continue
}
elements[i].ImageURL = filepath.Base(savePath)
}
}
// 7. 下载背景音乐(可选)
audioPath := ""
if audioURL != "" {
savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr)
} else {
// 7.5 音频降噪:去除气口/口水音/底噪(时长不变)
denoisedPath := filepath.Join(projectDir, "denoised_audio.wav")
if err := s.denoiseAudio(bgCtx, savePath, denoisedPath); err != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 音频降噪失败(使用原始音频): %v", taskID, err)
audioPath = savePath
} else {
audioPath = denoisedPath
g.Log().Infof(bgCtx, "[字幕 %s] 音频降噪完成: %s", taskID, denoisedPath)
}
}
}
// 8. 将外部字幕时间线转为字幕元素
subtitleElements := subtitlesToElements(subtitles, subtitleStyle)
// 合并所有元素
allElements := append(subtitleElements, elements...)
// 9. 生成 index.html(基于真实视频时长)
htmlContent := s.buildHTML(taskID, videoPaths[0], audioPath, allElements, totalVideoDuration, width, height)
htmlPath := filepath.Join(projectDir, "index.html")
if err := os.WriteFile(htmlPath, []byte(htmlContent), 0644); err != nil {
errMsg := fmt.Sprintf("生成HTML失败: %v", err)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath)
// 10. 执行 HyperFrames 渲染
outputPath := filepath.Join(projectDir, "output.mp4")
if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil {
errMsg := fmt.Sprintf("视频渲染失败: %v", err)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
// 7. 检查输出文件
stat, statErr := os.Stat(outputPath)
if statErr != nil {
errMsg := fmt.Sprintf("输出文件不存在: %v", statErr)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg)
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
durationStr := getVideoDurationStr(bgCtx, outputPath)
// 8. 上传到 MinIO(固定上传)
fileURL := ""
uploadCtx := context.WithValue(context.Background(), "user", user)
uploadRes, uploadErr := uploadToMinIO(uploadCtx, outputPath)
if uploadErr != nil {
dao.CaptionTask.UpdateError(bgCtx, taskID, fmt.Sprintf("上传失败: %v", uploadErr))
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
fileURL = uploadRes.FileURL
g.Log().Infof(bgCtx, "[字幕 %s] MinIO 上传完成: fileUrl=%s, fileName=%s", taskID, uploadRes.FileURL, uploadRes.FileName)
// 9. 更新为成功
fileName := filepath.Base(outputPath)
dao.CaptionTask.UpdateSuccess(bgCtx, taskID, fileURL, stat.Size(), fileName, durationStr)
g.Log().Infof(bgCtx, "[字幕 %s] 完成, 文件=%s, 大小=%d, 时长=%s", taskID, outputPath, stat.Size(), durationStr)
if callbackURL != "" {
extra := map[string]interface{}{
"fileURL": uploadRes.FileURL,
"fileSize": uploadRes.FileSize,
"fileName": uploadRes.FileName,
"fileFormat": uploadRes.FileFormat,
"fileAddressPrefix": uploadRes.FileAddressPrefix,
"durationStr": durationStr,
}
s.callback(bgCtx, taskID, callbackURL, extra)
}
}
// runHyperFramesRender 执行 HyperFrames 渲染
func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// 使用全局 hyperframes 命令(已在 setup 中安装检查)
hyperframesPath, lookErr := exec.LookPath("hyperframes")
if lookErr != nil {
return fmt.Errorf("hyperframes 未安装, 请运行: npm install -g hyperframes")
}
// 直接在工作目录运行 hyperframes render,输出默认 output.mp4
cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render")
cmd.Dir = projectDir
cmd.Env = append(os.Environ(),
"HYPERFRAMES_HEADLESS=true",
"HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium",
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("hyperframes render 失败: %v\n%s", err, string(output))
}
g.Log().Infof(ctx, "[HyperFrames] render 完成: %s", strings.TrimSpace(string(output)))
// 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置)
rendersDir := filepath.Join(projectDir, "renders")
if entries, readErr := os.ReadDir(rendersDir); readErr == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".mp4") {
src := filepath.Join(rendersDir, entry.Name())
if err := os.Rename(src, outputPath); err != nil {
return fmt.Errorf("移动输出文件失败: %v", err)
}
g.Log().Infof(ctx, "[HyperFrames] 找到输出文件: %s", entry.Name())
return nil
}
}
}
// 回退:检查 projectDir 下的 output.mp4
fallback := filepath.Join(projectDir, "output.mp4")
if _, statErr := os.Stat(fallback); statErr == nil {
if err := os.Rename(fallback, outputPath); err != nil {
return fmt.Errorf("移动输出文件失败: %v", err)
}
return nil
}
return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback)
}
// buildHTML 生成 HyperFrames HTML 模板
// videoPath: 已拼接好的单视频文件路径;totalDuration: 视频真实总时长(秒)
func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements []dto.CaptionElement, totalDuration float64, width, height int) string {
var sb strings.Builder
compID := "main"
sb.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n")
sb.WriteString("<meta charset=\"UTF-8\">\n")
sb.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
sb.WriteString("<style>\n")
sb.WriteString("* { margin: 0; padding: 0; box-sizing: border-box; }\n")
sb.WriteString(fmt.Sprintf("body { width: %dpx; height: %dpx; overflow: hidden; background: #000; }\n", width, height))
sb.WriteString(".clip { position: absolute; }\n")
sb.WriteString(".text-element { font-family: \"Noto Sans SC\", \"Noto Sans CJK SC\", \"PingFang SC\", \"Microsoft YaHei\", Arial, sans-serif; text-align: center; display: flex; align-items: center; justify-content: center; white-space: pre-wrap; word-break: break-word; }\n")
sb.WriteString("@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }\n")
sb.WriteString("@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }\n")
sb.WriteString("@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }\n")
sb.WriteString("@keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }\n")
sb.WriteString("@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }\n")
sb.WriteString(".anim-fadeIn { animation: fadeIn 0.5s ease-out; }\n")
sb.WriteString(".anim-slideUp { animation: slideUp 0.6s ease-out; }\n")
sb.WriteString(".anim-slideLeft { animation: slideLeft 0.6s ease-out; }\n")
sb.WriteString(".anim-scaleIn { animation: scaleIn 0.5s ease-out; }\n")
sb.WriteString(".anim-pulse { animation: pulse 1.5s ease-in-out infinite; }\n")
sb.WriteString("</style>\n")
sb.WriteString("<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap\" rel=\"stylesheet\">\n")
sb.WriteString("<script src=\"https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js\"></script>\n")
sb.WriteString("</head>\n<body>\n")
// Composition 容器(data-start="0" 必须)
sb.WriteString(fmt.Sprintf("<div id=\"stage\" data-composition-id=\"%s\" data-start=\"0\" data-width=\"%d\" data-height=\"%d\">\n", compID, width, height))
// 背景视频(独占 track=0
relVideoPath := filepath.Base(videoPath)
sb.WriteString(fmt.Sprintf(" <video id=\"bg-video\" class=\"clip\" data-start=\"0\" data-duration=\"%.3f\" data-track-index=\"0\" src=\"%s\" muted playsinline></video>\n",
totalDuration, relVideoPath))
// 字幕/图片元素(从 track=1 开始,避免与视频重叠警告)
clipIndex := 1
for _, elem := range elements {
elemID := fmt.Sprintf("elem-%d", clipIndex)
animClass := s.animationCSS(elem.Animation)
style := s.buildElemStyle(elem, width, height)
// duration<=0 表示一直显示到视频结束
elemDuration := elem.Duration
if elemDuration <= 0 {
elemDuration = totalDuration - elem.StartTime
if elemDuration <= 0 {
elemDuration = totalDuration
}
}
// 确保覆盖层不在 track=0(视频专用)
trackIdx := elem.TrackIndex
if trackIdx <= 0 {
trackIdx = 1
}
if elem.Type == "image" {
imgStyle := style
if elem.Width > 0 {
imgStyle += fmt.Sprintf("width:%dpx;", elem.Width)
}
if elem.Height > 0 {
imgStyle += fmt.Sprintf("height:%dpx;", elem.Height)
}
sb.WriteString(fmt.Sprintf(" <img id=\"%s\" class=\"clip %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" src=\"%s\" style=\"%s\" />\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, html.EscapeString(elem.ImageURL), imgStyle))
} else {
bgStyle := ""
if elem.BgColor != "" {
if elem.BgOpacity > 0 && elem.BgOpacity < 1 {
bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", hexToRGBA(elem.BgColor, elem.BgOpacity))
} else {
bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", elem.BgColor)
}
}
sb.WriteString(fmt.Sprintf(" <div id=\"%s\" class=\"clip text-element %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" style=\"%s%s\">%s</div>\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, bgStyle, html.EscapeString(elem.Text)))
}
clipIndex++
}
// 背景音乐
if audioPath != "" {
relAudioPath := filepath.Base(audioPath)
sb.WriteString(fmt.Sprintf(" <audio id=\"bg-audio\" class=\"clip\" data-start=\"0\" data-duration=\"%.3f\" data-track-index=\"%d\" data-volume=\"0.5\" src=\"%s\"></audio>\n",
totalDuration, clipIndex+100, relAudioPath))
}
sb.WriteString("</div>\n")
// GSAP 时间线
sb.WriteString("<script>\n")
sb.WriteString("(function() {\n")
sb.WriteString(" var tl = gsap.timeline({ paused: true });\n")
sb.WriteString(" tl.to('#stage', { duration: 0, opacity: 1 }, 0);\n")
sb.WriteString(" window.__timelines = window.__timelines || {};\n")
sb.WriteString(fmt.Sprintf(" window.__timelines['%s'] = tl;\n", compID))
sb.WriteString("})();\n")
sb.WriteString("</script>\n")
sb.WriteString("</body>\n</html>")
return sb.String()
}
// buildElemStyle 构建元素的 CSS 样式(X/Y 独立定位,transform 合并)
func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, canvasHeight int) string {
parts := []string{}
// X 轴定位
switch elem.X {
case "left", "":
parts = append(parts, "left:0px;")
case "center":
parts = append(parts, "left:50%;")
case "right":
parts = append(parts, "right:0px;")
default:
// 纯数字自动补 px,否则原样输出(如 calc、百分比等)
if isNumeric(elem.X) {
parts = append(parts, fmt.Sprintf("left:%spx;", elem.X))
} else {
parts = append(parts, fmt.Sprintf("left:%s;", elem.X))
}
}
// Y 轴定位
switch elem.Y {
case "top", "":
parts = append(parts, "top:0px;")
case "center":
parts = append(parts, "top:50%;")
case "bottom":
parts = append(parts, "bottom:0px;")
default:
if isNumeric(elem.Y) {
parts = append(parts, fmt.Sprintf("top:%spx;", elem.Y))
} else {
parts = append(parts, fmt.Sprintf("top:%s;", elem.Y))
}
}
// 合并 transform(避免 translateX 和 translateY 互相覆盖)
var transforms []string
if elem.X == "center" {
transforms = append(transforms, "translateX(-50%)")
}
if elem.Y == "center" {
transforms = append(transforms, "translateY(-50%)")
}
if len(transforms) > 0 {
parts = append(parts, fmt.Sprintf("transform:%s;", strings.Join(transforms, " ")))
}
// 文字样式
if elem.Type == "text" {
// 自动计算 max-width 防止文字溢出画布
// 底部居中字幕: 90% 宽度; 左/右贴边: 45% 宽度
maxWidthPct := 90
switch elem.X {
case "left", "right":
maxWidthPct = 45
case "center", "":
maxWidthPct = 90
}
maxWidth := int(float64(canvasWidth) * float64(maxWidthPct) / 100.0)
parts = append(parts, fmt.Sprintf("max-width:%dpx;", maxWidth))
if elem.FontSize > 0 {
parts = append(parts, fmt.Sprintf("font-size:%dpx;", elem.FontSize))
}
if elem.FontColor != "" {
parts = append(parts, fmt.Sprintf("color:%s;", elem.FontColor))
}
}
return strings.Join(parts, "")
}
// animationCSS 返回动画 CSS class
func (s *captionService) animationCSS(anim string) string {
switch anim {
case "fadeIn":
return "anim-fadeIn"
case "slideUp":
return "anim-slideUp"
case "slideLeft":
return "anim-slideLeft"
case "scaleIn":
return "anim-scaleIn"
case "pulse":
return "anim-pulse"
default:
return ""
}
}
// muteVideo 使用 FFmpeg 去掉视频中的音频轨道(消音)
func (s *captionService) muteVideo(ctx context.Context, inputPath, outputPath string) error {
ffmpegPath, err := lookupFFmpegPath()
if err != nil {
return err
}
args := []string{
"-i", inputPath,
"-an", // 去掉音频
"-c:v", "libx264", // 重新编码视频
"-crf", "23", // 画质(23=默认,越小越好)
"-preset", "veryfast", // 编码速度优先
"-y", outputPath,
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("FFmpeg 消音失败: %v\n%s", err, string(output))
}
return nil
}
// denoiseAudio 使用 FFmpeg afftdn 对音频降噪(去除气口/口水音/底噪,不改变时长)
func (s *captionService) denoiseAudio(ctx context.Context, inputPath, outputPath string) error {
ffmpegPath, err := lookupFFmpegPath()
if err != nil {
return err
}
args := []string{
"-i", inputPath,
"-af", "afftdn=nr=12:nf=-20", // 频域降噪:nr=降噪强度, nf=噪声底噪
"-y", outputPath,
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("FFmpeg 音频降噪失败: %v\n%s", err, string(output))
}
return nil
}
// concatVideos 用 FFmpeg 将多个视频拼接成一个
func (s *captionService) concatVideos(ctx context.Context, videoPaths []string, outputPath string) error {
ffmpegPath, err := lookupFFmpegPath()
if err != nil {
return err
}
// 使用 concat demuxer 拼接(最快,无需重编码)
fileListPath := outputPath + ".files.txt"
var lines []string
for _, vp := range videoPaths {
absPath, _ := filepath.Abs(vp)
lines = append(lines, "file '"+absPath+"'")
}
if err := os.WriteFile(fileListPath, []byte(strings.Join(lines, "\n")), 0644); err != nil {
return fmt.Errorf("创建文件列表失败: %v", err)
}
defer os.Remove(fileListPath)
args := []string{
"-f", "concat",
"-safe", "0",
"-i", fileListPath,
"-c", "copy",
"-y", outputPath,
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("FFmpeg 拼接失败: %v\n%s", err, string(output))
}
return nil
}
// getVideoRealDuration 用 ffprobe 获取视频时长(秒,float)
func getVideoRealDuration(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
}
// getVideoDurationStr 获取视频时长可读字符串
func getVideoDurationStr(ctx context.Context, videoPath string) string {
ffprobePath, err := exec.LookPath("ffprobe")
if err != nil {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串")
return ""
}
args := []string{
"-v", "quiet",
"-print_format", "json",
"-show_format",
videoPath,
}
cmd := exec.CommandContext(ctx, ffprobePath, args...)
output, err := cmd.Output()
if err != nil {
return ""
}
var info struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &info); err != nil {
return ""
}
var secs float64
fmt.Sscanf(info.Format.Duration, "%f", &secs)
if secs <= 0 {
return ""
}
m := int(secs) / 60
s := int(secs) % 60
return fmt.Sprintf("%d:%02d", m, s)
}
// getVideoResolution 使用 ffprobe 获取视频分辨率
func getVideoResolution(ctx context.Context, videoPath string) (width, height int) {
ffprobePath, err := exec.LookPath("ffprobe")
if err != nil {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920")
return 1080, 1920 // 默认竖屏
}
args := []string{
"-v", "quiet",
"-print_format", "json",
"-select_streams", "v:0",
"-show_streams",
videoPath,
}
cmd := exec.CommandContext(ctx, ffprobePath, args...)
output, err := cmd.Output()
if err != nil {
return 1080, 1920
}
var info struct {
Streams []struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &info); err != nil || len(info.Streams) == 0 {
return 1080, 1920
}
w := info.Streams[0].Width
h := info.Streams[0].Height
if w <= 0 || h <= 0 {
return 1080, 1920
}
return w, h
}
// hexToRGBA 将十六进制颜色(如 #FF0000)转为 rgba(r,g,b,a) 字符串
func hexToRGBA(hex string, alpha float64) string {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 && len(hex) != 3 {
return hex
}
if len(hex) == 3 {
// 简写 #RGB → #RRGGBB
hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
}
r, _ := strconv.ParseInt(hex[0:2], 16, 0)
g, _ := strconv.ParseInt(hex[2:4], 16, 0)
b, _ := strconv.ParseInt(hex[4:6], 16, 0)
return fmt.Sprintf("rgba(%d,%d,%d,%.2f)", r, g, b, alpha)
}
// ---------- 字幕时间线转元素 ----------
// subtitlesToElements 将外部传入的字幕时间线转为底部字幕元素
func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleStyle) []dto.CaptionElement {
// 应用默认值
fontSize := 28
fontColor := "#FFFFFF"
bgColor := "#000000"
bgOpacity := 0.6
if style != nil {
if style.FontSize > 0 {
fontSize = style.FontSize
}
if style.FontColor != "" {
fontColor = style.FontColor
}
// BgColor/BgOpacity 使用 *string/*float64,精确区分"没传"和"传了零值"
// - 没传字段 → 指针为 nil → 使用默认值
// - 传了 "" 或 0 → 指针非 nil → 按用户意愿设置
if style.BgColor != nil {
bgColor = *style.BgColor
}
if style.BgOpacity != nil {
bgOpacity = *style.BgOpacity
}
}
// 从样式配置中读取字幕位置
subtitleX := "center"
subtitleY := "bottom"
if style != nil {
if style.X != "" {
subtitleX = style.X
}
if style.Y != "" {
subtitleY = style.Y
}
}
g.Log().Infof(context.TODO(), "[字幕定位] subtitleX=%s subtitleY=%s", subtitleX, subtitleY)
var elements []dto.CaptionElement
for _, seg := range subtitles {
text := strings.TrimSpace(seg.Text)
if text == "" {
continue
}
elements = append(elements, dto.CaptionElement{
Type: "text",
Text: text,
StartTime: seg.Start,
Duration: seg.End - seg.Start,
X: subtitleX,
Y: subtitleY,
FontSize: fontSize,
FontColor: fontColor,
BgColor: bgColor,
BgOpacity: bgOpacity,
TrackIndex: 10,
})
}
return elements
}
// ---------- 查询任务 ----------
// GetTaskResult 查询字幕任务结果
func (s *captionService) GetTaskResult(ctx context.Context, taskID string) (*dto.GetCaptionTaskRes, error) {
task, err := dao.CaptionTask.GetByTaskID(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("查询任务失败: %v", err)
}
if task == nil {
return nil, fmt.Errorf("任务不存在: %s", taskID)
}
return dao.EntityToCaptionTaskRes(task), nil
}
// ---------- 回调通知 ----------
func (s *captionService) callback(ctx context.Context, taskID, callbackURL string, extraPayload map[string]interface{}) {
if callbackURL == "" {
return
}
task, err := dao.CaptionTask.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 extraPayload != nil {
for k, v := range extraPayload {
payload[k] = v
}
} else {
// 否则从 DB 读取(兼容失败场景、旧回调路径)
if task.Status == "success" {
payload["fileURL"] = task.FileURL
payload["fileSize"] = task.FileSize
payload["durationStr"] = task.DurationStr
}
if task.Status == "failed" {
payload["errorMessage"] = task.ErrorMessage
}
}
body, _ := json.Marshal(payload)
g.Log().Infof(ctx, "[字幕回调 %s] 状态=%s, 目标=%s, body=%s", taskID, task.Status, callbackURL, string(body))
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser)
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))
}
// isNumeric 判断字符串是否为纯数字(含小数)
func isNumeric(s string) bool {
_, err := strconv.ParseFloat(s, 64)
return err == nil
}