Files
media/service/video/template_service.go
T
2026-08-28 13:38:40 +08:00

429 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package video
import (
"bytes"
"context"
"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 ""
}