838 lines
27 KiB
Go
838 lines
27 KiB
Go
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",
|
||
)
|
||
|
||
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: Arial, Helvetica, 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("<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
|
||
}
|