429 lines
12 KiB
Go
429 lines
12 KiB
Go
package video
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
dto "media/model/dto/video"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/util/guid"
|
||
"golang.org/x/net/html"
|
||
)
|
||
|
||
// Template 模板视频服务单例
|
||
// 职责:将 HTML 模板解析为 CaptionElement,然后复用 Caption 服务的完整渲染流程
|
||
var Template = new(templateService)
|
||
|
||
type templateService struct{}
|
||
|
||
// CreateAsyncTask 创建模板视频任务
|
||
// 解析 HTML 模板 → 转为 CaptionElement → 调用 Caption 服务的异步任务
|
||
func (s *templateService) CreateAsyncTask(ctx context.Context, videoURLs []string, audioURL string, templates []dto.HtmlTemplate, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, callbackURL string) (string, error) {
|
||
if len(videoURLs) < 1 {
|
||
return "", fmt.Errorf("至少需要1个视频")
|
||
}
|
||
|
||
// templates 为空:跳过模板解析与渲染,直接转交 Caption 服务(拼接视频+混入音频+叠加字幕)
|
||
if len(templates) < 1 {
|
||
g.Log().Infof(ctx, "[模板解析] templates 为空, 跳过模板逻辑, 直接拼接视频+音频+字幕")
|
||
return Caption.CreateAsyncTask(ctx, videoURLs, audioURL, subtitles, subtitleStyle, nil, callbackURL)
|
||
}
|
||
|
||
// 创建临时目录用于下载模板和图片
|
||
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
|
||
workDir := filepath.Join(tempDir, "tmpl_parse_"+guid.S())
|
||
os.RemoveAll(workDir)
|
||
os.MkdirAll(workDir, 0755)
|
||
defer os.RemoveAll(workDir)
|
||
|
||
// 解析所有 HTML 模板为 CaptionElement
|
||
var allElements []dto.CaptionElement
|
||
for i, tmpl := range templates {
|
||
tmplStart := time.Now()
|
||
g.Log().Infof(ctx, "[模板解析] 解析模板%d/%d: url=%s, start=%.2f, end=%.2f", i+1, len(templates), tmpl.URL, tmpl.Start, tmpl.End)
|
||
elements, err := s.parseSingleTemplate(ctx, tmpl, workDir)
|
||
if err != nil {
|
||
return "", fmt.Errorf("解析模板%d失败: %v", i, err)
|
||
}
|
||
g.Log().Infof(ctx, "[模板解析] 模板%d/%d 解析完成: 元素数=%d, 耗时=%s", i+1, len(templates), len(elements), time.Since(tmplStart))
|
||
allElements = append(allElements, elements...)
|
||
}
|
||
|
||
g.Log().Infof(ctx, "[模板解析] 共解析出 %d 个元素, 转交 Caption 服务处理", len(allElements))
|
||
|
||
// 直接调用 Caption 服务的异步任务(复用完整渲染流程)
|
||
return Caption.CreateAsyncTask(ctx, videoURLs, audioURL, subtitles, subtitleStyle, allElements, callbackURL)
|
||
}
|
||
|
||
// parseSingleTemplate 解析单个 HTML 模板,提取 CaptionElement 列表
|
||
func (s *templateService) parseSingleTemplate(ctx context.Context, tmpl dto.HtmlTemplate, downloadDir string) ([]dto.CaptionElement, error) {
|
||
// 1. 下载模板 HTML
|
||
dlStart := time.Now()
|
||
g.Log().Infof(ctx, "[模板解析] 开始下载模板HTML: %s", tmpl.URL)
|
||
htmlPath, err := downloadFile(ctx, tmpl.URL, downloadDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("下载模板HTML失败: %v", err)
|
||
}
|
||
g.Log().Infof(ctx, "[模板解析] 模板HTML下载完成: %s, 耗时=%s", tmpl.URL, time.Since(dlStart))
|
||
|
||
// 2. 读取内容
|
||
htmlBytes, err := os.ReadFile(htmlPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取模板HTML失败: %v", err)
|
||
}
|
||
|
||
// 3. 解析 HTML 树
|
||
doc, err := html.Parse(bytes.NewReader(htmlBytes))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析模板HTML失败: %v", err)
|
||
}
|
||
|
||
// 4. 遍历 HTML 提取元素
|
||
var elements []dto.CaptionElement
|
||
s.collectElements(doc, tmpl.Data, tmpl.Start, tmpl.End, downloadDir, ctx, &elements)
|
||
|
||
return elements, nil
|
||
}
|
||
|
||
// collectElements 递归遍历 HTML 节点,找出带 data-key 的元素并转为 CaptionElement
|
||
func (s *templateService) collectElements(n *html.Node, data map[string]string, offset, tmplEnd float64, downloadDir string, ctx context.Context, elements *[]dto.CaptionElement) {
|
||
if n.Type == html.ElementNode {
|
||
key := getAttr(n, "data-key")
|
||
if key != "" && data != nil {
|
||
if value, ok := data[key]; ok && value != "" {
|
||
elem := s.nodeToElement(n, key, value, offset, tmplEnd, downloadDir, ctx)
|
||
if elem != nil {
|
||
*elements = append(*elements, *elem)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 递归子节点
|
||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||
s.collectElements(c, data, offset, tmplEnd, downloadDir, ctx, elements)
|
||
}
|
||
}
|
||
|
||
// nodeToElement 将 HTML 节点转换为 CaptionElement
|
||
func (s *templateService) nodeToElement(n *html.Node, key, value string, offset, tmplEnd float64, downloadDir string, ctx context.Context) *dto.CaptionElement {
|
||
elem := &dto.CaptionElement{
|
||
Type: "text",
|
||
Text: value,
|
||
Animation: "",
|
||
TrackIndex: 1,
|
||
}
|
||
|
||
// --- data-* 属性解析 ---
|
||
|
||
// data-start(需要加偏移量)
|
||
startStr := getAttr(n, "data-start")
|
||
if startStr != "" {
|
||
if startVal, err := strconv.ParseFloat(startStr, 64); err == nil {
|
||
elem.StartTime = startVal + offset
|
||
}
|
||
}
|
||
|
||
// data-duration(自动裁剪到模板结束时间)
|
||
durStr := getAttr(n, "data-duration")
|
||
if durStr != "" {
|
||
if durVal, err := strconv.ParseFloat(durStr, 64); err == nil {
|
||
if durVal > 0 {
|
||
elem.Duration = durVal
|
||
}
|
||
}
|
||
}
|
||
// 如果没有 duration 或 =0,自动按模板结束时间计算
|
||
if elem.Duration <= 0 && tmplEnd > offset {
|
||
elem.Duration = tmplEnd - elem.StartTime
|
||
if elem.Duration <= 0 {
|
||
elem.Duration = tmplEnd - offset
|
||
}
|
||
}
|
||
|
||
// data-track-index
|
||
trackStr := getAttr(n, "data-track-index")
|
||
if trackStr != "" {
|
||
if trackVal, err := strconv.Atoi(trackStr); err == nil {
|
||
elem.TrackIndex = trackVal
|
||
}
|
||
}
|
||
|
||
// --- 类型判断 ---
|
||
if n.Data == "img" {
|
||
elem.Type = "image"
|
||
elem.ImageURL = value // 图片URL(Caption服务后续会下载到本地)
|
||
elem.Text = ""
|
||
}
|
||
|
||
// --- 从 style 属性解析定位和样式 ---
|
||
styleStr := getAttr(n, "style")
|
||
if styleStr != "" {
|
||
styles := parseInlineStyle(styleStr)
|
||
s.applyStyles(elem, styles)
|
||
}
|
||
|
||
// --- 如果当前元素本身没有背景色,检查子 span 是否带背景(如免责声明等嵌套结构) ---
|
||
if elem.BgColor == "" && !strings.HasPrefix(styleStr, "background") && n.FirstChild != nil {
|
||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||
if c.Type == html.ElementNode && c.Data == "span" {
|
||
childStyle := getAttr(c, "style")
|
||
if childStyle != "" {
|
||
childStyles := parseInlineStyle(childStyle)
|
||
if bg, ok := childStyles["background"]; ok && bg != "" {
|
||
s.applyStyles(elem, childStyles)
|
||
}
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 从 width/height 属性解析图片尺寸(仅 img 元素) ---
|
||
if n.Data == "img" {
|
||
wStr := getAttr(n, "width")
|
||
if wStr != "" {
|
||
if wVal, err := strconv.Atoi(wStr); err == nil {
|
||
elem.Width = wVal
|
||
}
|
||
}
|
||
hStr := getAttr(n, "height")
|
||
if hStr != "" {
|
||
if hVal, err := strconv.Atoi(hStr); err == nil {
|
||
elem.Height = hVal
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 从 class 解析动画 ---
|
||
classStr := getAttr(n, "class")
|
||
if classStr != "" {
|
||
classes := strings.Fields(classStr)
|
||
for _, cls := range classes {
|
||
if anim := classToAnimation(cls); anim != "" {
|
||
elem.Animation = anim
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
return elem
|
||
}
|
||
|
||
// applyStyles 将 CSS 样式映射到 CaptionElement 字段
|
||
func (s *templateService) applyStyles(elem *dto.CaptionElement, styles map[string]string) {
|
||
// X 轴定位
|
||
if left, ok := styles["left"]; ok {
|
||
left = strings.TrimSpace(left)
|
||
elem.X = cssPositionToX(left, styles)
|
||
} else if _, ok := styles["right"]; ok {
|
||
elem.X = "right"
|
||
}
|
||
|
||
// Y 轴定位
|
||
if top, ok := styles["top"]; ok {
|
||
top = strings.TrimSpace(top)
|
||
elem.Y = cssPositionToY(top, styles)
|
||
} else if bottom, ok := styles["bottom"]; ok {
|
||
bottom = strings.TrimSpace(bottom)
|
||
if bottom == "0" || bottom == "0px" {
|
||
// bottom:0 → 语义化 "bottom",渲染端输出 bottom:0px(正确贴底)
|
||
elem.Y = "bottom"
|
||
} else {
|
||
// bottom: Npx → 需元素高度才能换算 top,解析阶段拿不到高度;
|
||
// 保留 calc(100% - Npx)(顶边在 100%-N 处)。带高度元素请用
|
||
// top: calc(100% - 高度px - Npx) 写法
|
||
elem.Y = "calc(100% - " + bottom + ")"
|
||
}
|
||
}
|
||
|
||
// 竖排显示(writing-mode: vertical-rl / vertical-lr)
|
||
if wm, ok := styles["writing-mode"]; ok {
|
||
if strings.Contains(strings.TrimSpace(wm), "vertical") {
|
||
elem.Vertical = true
|
||
}
|
||
}
|
||
|
||
// 字号
|
||
if fontSize, ok := styles["font-size"]; ok {
|
||
elem.FontSize = parsePxToInt(fontSize)
|
||
}
|
||
|
||
// 字体颜色
|
||
if color, ok := styles["color"]; ok {
|
||
elem.FontColor = strings.TrimSpace(color)
|
||
}
|
||
|
||
// 水平对齐
|
||
if ta, ok := styles["text-align"]; ok {
|
||
elem.TextAlign = strings.TrimSpace(ta)
|
||
}
|
||
|
||
// 字体描边(如 -webkit-text-stroke: 3px #000000,白字黑描边效果)
|
||
if stroke, ok := styles["-webkit-text-stroke"]; ok {
|
||
elem.FontStroke = strings.TrimSpace(stroke)
|
||
}
|
||
|
||
// 背景色 + 透明度
|
||
if bg, ok := styles["background"]; ok {
|
||
bg = strings.TrimSpace(bg)
|
||
if strings.HasPrefix(bg, "rgba(") {
|
||
// rgba(r,g,b,a) 格式
|
||
bg = strings.TrimPrefix(bg, "rgba(")
|
||
bg = strings.TrimSuffix(bg, ")")
|
||
parts := strings.Split(bg, ",")
|
||
if len(parts) == 4 {
|
||
elem.BgColor = fmt.Sprintf("#%02x%02x%02x", parseHexByte(strings.TrimSpace(parts[0])), parseHexByte(strings.TrimSpace(parts[1])), parseHexByte(strings.TrimSpace(parts[2])))
|
||
if opacity, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64); err == nil {
|
||
elem.BgOpacity = opacity
|
||
}
|
||
}
|
||
} else if strings.HasPrefix(bg, "#") {
|
||
elem.BgColor = bg
|
||
} else if strings.HasPrefix(bg, "rgb(") {
|
||
elem.BgColor = bg
|
||
}
|
||
}
|
||
|
||
// 图片宽高
|
||
if w, ok := styles["width"]; ok && elem.Width <= 0 {
|
||
w = strings.TrimSpace(w)
|
||
if px := parsePxToInt(w); px > 0 {
|
||
elem.Width = px
|
||
} else if w != "" {
|
||
// 非整数宽度(如 calc(100% - 320px)),原样透传,文字可精确控制换行范围
|
||
elem.WidthStr = w
|
||
}
|
||
}
|
||
if h, ok := styles["height"]; ok && elem.Height <= 0 {
|
||
elem.Height = parsePxToInt(h)
|
||
}
|
||
}
|
||
|
||
// ---------- CSS 解析工具 ----------
|
||
|
||
// parseInlineStyle 将 style 属性字符串解析为 map
|
||
func parseInlineStyle(style string) map[string]string {
|
||
result := make(map[string]string)
|
||
parts := strings.Split(style, ";")
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
colonIdx := strings.Index(part, ":")
|
||
if colonIdx < 0 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(part[:colonIdx])
|
||
val := strings.TrimSpace(part[colonIdx+1:])
|
||
result[key] = val
|
||
}
|
||
return result
|
||
}
|
||
|
||
// cssPositionToX 将 CSS left/transform 转为 X 定位值
|
||
func cssPositionToX(left string, styles map[string]string) string {
|
||
transform := styles["transform"]
|
||
left = stripPx(left)
|
||
|
||
if left == "0" || left == "0px" {
|
||
return "left"
|
||
}
|
||
if left == "50%" && strings.Contains(transform, "translateX(-50%)") {
|
||
return "center"
|
||
}
|
||
if left == "50%" && strings.Contains(transform, "translateX(50%)") {
|
||
return "center"
|
||
}
|
||
return left
|
||
}
|
||
|
||
// cssPositionToY 将 CSS top/transform 转为 Y 定位值
|
||
func cssPositionToY(top string, styles map[string]string) string {
|
||
transform := styles["transform"]
|
||
top = stripPx(top)
|
||
|
||
if top == "0" || top == "0px" {
|
||
return "top"
|
||
}
|
||
if top == "50%" && strings.Contains(transform, "translateY(-50%)") {
|
||
return "center"
|
||
}
|
||
return top
|
||
}
|
||
|
||
// stripPx 仅当整串为纯数值(N 或 Npx)时去掉 px 后缀,避免破坏 calc(...) 等表达式。
|
||
// 例如 "200px" → "200",而 "calc(100% - 200px)" 原样保留。
|
||
func stripPx(s string) string {
|
||
trimmed := strings.TrimSpace(s)
|
||
if strings.HasSuffix(trimmed, "px") {
|
||
core := strings.TrimSuffix(trimmed, "px")
|
||
if isNumeric(core) {
|
||
return core
|
||
}
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
// parsePxToInt 从 "48px" 或 "48" 解析出 int
|
||
func parsePxToInt(s string) int {
|
||
s = strings.TrimSpace(s)
|
||
s = strings.TrimSuffix(s, "px")
|
||
s = strings.TrimSuffix(s, "PX")
|
||
val, err := strconv.Atoi(s)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return val
|
||
}
|
||
|
||
// parseHexByte 解析数字字符串(可能是 "255" 或 "0xff" 等)为 byte,用于 rgba 转换
|
||
func parseHexByte(s string) byte {
|
||
val, err := strconv.Atoi(s)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
if val > 255 {
|
||
return 255
|
||
}
|
||
if val < 0 {
|
||
return 0
|
||
}
|
||
return byte(val)
|
||
}
|
||
|
||
// classToAnimation 从 CSS class 名推断动画类型
|
||
func classToAnimation(cls string) string {
|
||
switch {
|
||
case strings.Contains(cls, "fadeIn"):
|
||
return "fadeIn"
|
||
case strings.Contains(cls, "slideUp"):
|
||
return "slideUp"
|
||
case strings.Contains(cls, "slideLeft"):
|
||
return "slideLeft"
|
||
case strings.Contains(cls, "scaleIn"):
|
||
return "scaleIn"
|
||
case strings.Contains(cls, "pulse"):
|
||
return "pulse"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// getAttr 获取 HTML 节点属性值
|
||
func getAttr(n *html.Node, key string) string {
|
||
for _, attr := range n.Attr {
|
||
if attr.Key == key {
|
||
return attr.Val
|
||
}
|
||
}
|
||
return ""
|
||
}
|