Files
2026-08-18 13:43:11 +08:00

1030 lines
36 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"
"runtime"
"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, err := json.Marshal(videoURLs)
if err != nil {
return "", fmt.Errorf("序列化视频URL失败: %v", err)
}
elementsJSON, err := json.Marshal(elements)
if err != nil {
return "", fmt.Errorf("序列化元素失败: %v", err)
}
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 {
dlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr)
continue
}
g.Log().Infof(bgCtx, "[字幕 %s] 视频%d下载完成: %s, 耗时=%s", taskID, i, videoURL, time.Since(dlStart))
videoPaths = append(videoPaths, savePath)
}
if len(videoPaths) < 1 {
errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs))
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
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 != "" {
audioDlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr)
} else {
g.Log().Infof(bgCtx, "[字幕 %s] 音频下载完成: %s, 耗时=%s", taskID, audioURL, time.Since(audioDlStart))
// 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)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
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)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil)
return
}
// 7. 检查输出文件
stat, statErr := os.Stat(outputPath)
if statErr != nil {
errMsg := fmt.Sprintf("输出文件不存在: %v", statErr)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
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 {
errMsg := fmt.Sprintf("上传失败: %v", uploadErr)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
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 {
timeoutMin := g.Cfg().MustGet(ctx, "hyperframes.render_timeout", 30).Int()
ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(timeoutMin)*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
// 从配置文件读取并构建环境变量
headless := g.Cfg().MustGet(ctx, "hyperframes.headless", true).Bool()
browserPath := g.Cfg().MustGet(ctx, "hyperframes.browser_path", "").String()
ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String()
env := os.Environ()
// 用短路径作为 hyperframes/Chrome 的 TMPDIR。
// 原因:Chrome 会在 TMPDIR 下创建 puppeteer 临时 profilepuppeteer_dev_chrome_profile-XXXXXX
// 及 Unix socket,而 sockaddr_un.sun_path 上限 108 字节。若 TMPDIR 过长(如
// /app/resource/temp/caption_cap_<36位taskID>/hf_tmp,加上 profile 后已 >108),socket 创建失败,
// 浏览器启动即崩溃 —— puppeteer 报 "Failed to launch the browser process: Code: null",渲染失败。
// Linux 直接用 /tmp(很短);Windows 保留"与工作目录同盘"约束(避免跨盘符号链接失败),
// 用项目盘符下的短路径。
tmpBase := os.TempDir() // Linux: /tmpWindows: 系统临时目录
if runtime.GOOS == "windows" {
if volume := filepath.VolumeName(projectDir); volume != "" {
tmpBase = filepath.Join(volume+string(os.PathSeparator), "hf_tmp")
}
}
os.MkdirAll(tmpBase, 0755)
env = append(env, "TMP="+tmpBase, "TEMP="+tmpBase, "TMPDIR="+tmpBase)
g.Log().Infof(ctx, "[HyperFrames] 设置 TMP=%s (短路径,避免 Unix socket 超长; Windows 同盘避免跨盘符号链接)", tmpBase)
if headless {
env = append(env, "HYPERFRAMES_HEADLESS=true")
}
// 解析实际要用的浏览器路径:
// - 配置了 browser_path 且路径存在 → 直接用
// - 不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser)或未配置 → 自动探测本机系统
// Chrome/Chromium,避免 HyperFrames 回退到从 Google CDN 慢速下载(国内网络会长时间卡住)
resolvedBrowserPath := ""
if browserPath != "" {
if _, statErr := os.Stat(browserPath); statErr == nil {
resolvedBrowserPath = browserPath
} else {
g.Log().Warningf(ctx, "[HyperFrames] 配置的 hyperframes.browser_path=%s 不存在(%v),自动探测系统浏览器", browserPath, statErr)
}
}
if resolvedBrowserPath == "" {
resolvedBrowserPath = detectSystemChromePath()
}
if resolvedBrowserPath != "" {
env = append(env, "HYPERFRAMES_BROWSER_PATH="+resolvedBrowserPath)
g.Log().Infof(ctx, "[HyperFrames] 使用浏览器: %s", resolvedBrowserPath)
}
if ffmpegCfgPath != "" {
ffmpegDir := filepath.Dir(ffmpegCfgPath)
// 把 FFmpeg 目录插入 PATH 最前面,让 HyperFrames 能找到 ffmpeg/ffprobe
// Windows 上环境变量名是 Path(大小写不敏感)
pathFound := false
for i, e := range env {
if len(e) > 5 && strings.EqualFold(e[:5], "PATH=") {
env[i] = "PATH=" + ffmpegDir + string(os.PathListSeparator) + e[5:]
pathFound = true
break
}
}
if !pathFound {
env = append(env, "PATH="+ffmpegDir)
}
g.Log().Infof(ctx, "[HyperFrames] 已将 FFmpeg 目录加入 PATH: %s", ffmpegDir)
}
cmd.Env = env
// 实时转发 hyperframes 子进程输出到本服务 stdout:渲染可能长达数分钟,
// 若用 CombinedOutput 缓冲,控制台在渲染期间会长时间无输出,无法判断是否卡住。
// 输出同时保留到内存,供失败时诊断。
var renderOutput bytes.Buffer
cmd.Stdout = io.MultiWriter(os.Stdout, &renderOutput)
cmd.Stderr = io.MultiWriter(os.Stdout, &renderOutput)
renderStart := time.Now()
g.Log().Infof(ctx, "[HyperFrames] 开始渲染: 项目目录=%s, 渲染超时=%d分钟", projectDir, timeoutMin)
if err := cmd.Run(); err != nil {
return fmt.Errorf("hyperframes render 失败(耗时%s): %v\n%s", time.Since(renderStart), err, renderOutput.String())
}
g.Log().Infof(ctx, "[HyperFrames] render 完成(耗时%s): %s", time.Since(renderStart), strings.TrimSpace(renderOutput.String()))
// 查找输出文件:优先 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)
}
// detectSystemChromePath 探测本机系统级 Chrome/Chromium 可执行文件路径。
// 用于配置的 browser_path 在当前平台不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser
// 或未配置时,自动找到可用的浏览器,避免 HyperFrames 回退到从 Google CDN 慢速下载。
func detectSystemChromePath() string {
// 优先级最高:显式设置的环境变量(用户可在 OS 层指定,Go 进程继承后这里能读到)
if p := os.Getenv("HYPERFRAMES_BROWSER_PATH"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
switch runtime.GOOS {
case "windows":
// Chrome 标准安装路径(用户级/系统级)
for _, p := range []string{
filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"),
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
case "linux":
for _, p := range []string{"/usr/bin/chromium-browser", "/usr/bin/chromium"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
return ""
}
// 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 := ""
hasBg := false
if elem.BgColor != "" {
hasBg = true
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)
}
}
if hasBg {
sb.WriteString(fmt.Sprintf(" <div id=\"%s\" class=\"clip text-element %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" style=\"%s\"><span style=\"%s\">%s</span></div>\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, bgStyle, html.EscapeString(elem.Text)))
} else {
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</div>\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, 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" {
if elem.Vertical {
// 竖排显示:writing-mode 竖排,宽度自动收缩为单列,不强制百分比宽度
parts = append(parts, "writing-mode:vertical-rl;")
} else {
if elem.WidthStr != "" {
// 模板指定了非整数宽度(如 calc(100% - 320px)):文字宽度随画布自适应,
// 始终落在两图之间的空隙里,避免压到图片
parts = append(parts, fmt.Sprintf("width:%s;", elem.WidthStr))
parts = append(parts, fmt.Sprintf("max-width:%s;", elem.WidthStr))
} else if elem.Width > 0 {
// 模板指定了像素宽度:文字宽度精确受控,不再按画布百分比
parts = append(parts, fmt.Sprintf("width:%dpx;", elem.Width))
parts = append(parts, fmt.Sprintf("max-width:%dpx;", elem.Width))
} else {
// 自动计算 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("width:%dpx;", maxWidth))
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))
}
if elem.FontStroke != "" {
parts = append(parts, fmt.Sprintf("-webkit-text-stroke:%s;", elem.FontStroke))
}
// 水平对齐:覆盖全局 .text-element 的居中(flex 容器用 justify-content,多行文字用 text-align
switch elem.TextAlign {
case "left":
parts = append(parts, "text-align:left;")
parts = append(parts, "justify-content:flex-start;")
case "right":
parts = append(parts, "text-align:right;")
parts = append(parts, "justify-content:flex-end;")
}
}
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", // 编码速度优先
// 固定关键帧间隔(30帧=1秒@30fps),避免产生稀疏关键帧:
// HyperFrames 对稀疏关键帧会告警 "sparse keyframes (max interval: Xs). This causes
// seek failures and frame freezing",浏览器在 probe 阶段 seek 时可能异常甚至崩溃
"-g", "30",
"-keyint_min", "30",
"-movflags", "+faststart", // MOOV 前置,便于浏览器快速播放
"-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 := lookupFFprobePath()
if ffprobePath == "" {
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 := lookupFFprobePath()
if ffprobePath == "" {
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 := lookupFFprobePath()
if ffprobePath == "" {
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 := 38
fontColor := "#FFFFFF"
fontStroke := "0.5px #000000" // 默认极细黑描边
bgColor := "" // 默认无背景色(透明)
bgOpacity := 0.4
if style != nil {
if style.FontSize > 0 {
fontSize = style.FontSize
}
if style.FontColor != "" {
fontColor = style.FontColor
}
// FontStroke/BgColor/BgOpacity 使用指针,精确区分"没传"和"传了零值"
// - 没传字段 → 指针为 nil → 使用默认值
// - 传了 "" 或 0 → 指针非 nil → 按用户意愿设置
if style.FontStroke != nil {
fontStroke = *style.FontStroke
}
if style.BgColor != nil {
bgColor = *style.BgColor
}
if style.BgOpacity != nil {
bgOpacity = *style.BgOpacity
}
}
// 从样式配置中读取字幕位置
subtitleX := "center"
subtitleY := "65%"
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,
FontStroke: fontStroke,
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, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 序列化回调payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[字幕回调 %s] 状态=%s, 目标=%s, body=%s", taskID, task.Status, callbackURL, 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")
cbUser := getUserFromCtx(ctx)
userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
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))
}
// isNumeric 判断字符串是否为纯数字(含小数)
func isNumeric(s string) bool {
_, err := strconv.ParseFloat(s, 64)
return err == nil
}