根据模版渲染视频
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dto "media/model/dto/video"
|
||||
service "media/service/video"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type template struct{}
|
||||
|
||||
var Template = new(template)
|
||||
|
||||
// CreateTemplate 创建模板视频任务 POST /video/template
|
||||
func (c *template) CreateTemplate(ctx context.Context, req *dto.CreateTemplateTaskReq) (res *dto.CreateTemplateTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[模板视频] 收到请求 入参: video_count=%d, template_count=%d, callback=%s",
|
||||
len(req.VideoURLs), len(req.Templates), req.CallbackURL)
|
||||
|
||||
if len(req.VideoURLs) < 1 {
|
||||
return nil, fmt.Errorf("至少需要1个视频")
|
||||
}
|
||||
if len(req.Templates) < 1 {
|
||||
return nil, fmt.Errorf("至少需要1个HTML模板")
|
||||
}
|
||||
|
||||
taskID, taskErr := service.Template.CreateAsyncTask(ctx, req.VideoURLs, req.AudioURL, req.Templates, req.Subtitles, req.SubtitleStyle, req.CallbackURL)
|
||||
if taskErr != nil {
|
||||
return nil, taskErr
|
||||
}
|
||||
|
||||
return &dto.CreateTemplateTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// GetTemplateTask 查询模板任务结果 GET /video/template/{taskId}
|
||||
// 实际委托给字幕叠加服务的查询(任务由 Caption 服务创建)
|
||||
func (c *template) GetTemplateTask(ctx context.Context, req *dto.GetTemplateTaskReq) (res *dto.GetTemplateTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
captionRes, err := service.Caption.GetTaskResult(ctx, req.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 映射响应
|
||||
return &dto.GetTemplateTaskRes{
|
||||
TaskID: captionRes.TaskID,
|
||||
Status: captionRes.Status,
|
||||
FileURL: captionRes.FileURL,
|
||||
FileSize: captionRes.FileSize,
|
||||
FileName: captionRes.FileName,
|
||||
DurationStr: captionRes.DurationStr,
|
||||
ErrorMessage: captionRes.ErrorMessage,
|
||||
}, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
controllerAudio "media/controller/audio"
|
||||
controllerVideo "media/controller/video"
|
||||
|
||||
@@ -27,6 +28,7 @@ func main() {
|
||||
controllerVideo.Caption,
|
||||
controllerVideo.Transcode,
|
||||
controllerVideo.SceneSplit,
|
||||
controllerVideo.Template,
|
||||
})
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package video
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ---------- HTML 模板定义 ----------
|
||||
|
||||
// HtmlTemplate 单段HTML模板
|
||||
type HtmlTemplate struct {
|
||||
URL string `json:"url" v:"required#模板URL不能为空" dc:"模板HTML文件URL,HyperFrames格式,元素使用data-key做占位符"`
|
||||
Start float64 `json:"start" d:"0" dc:"模板开始时间(秒)"`
|
||||
End float64 `json:"end" dc:"模板结束时间(秒),不传=持续到视频结束"`
|
||||
Data map[string]string `json:"data" dc:"模板数据,key对应HTML中元素的data-key属性,value为替换内容。图片URL自动下载"`
|
||||
}
|
||||
|
||||
// ---------- 创建模板任务 ----------
|
||||
|
||||
// CreateTemplateTaskReq 创建模板视频任务请求
|
||||
type CreateTemplateTaskReq struct {
|
||||
g.Meta `path:"/template" method:"post" tags:"模板视频" summary:"创建模板视频任务(异步)" dc:"使用HTML模板+数据创建营销视频,支持多段模板不同时间区间,返回taskId"`
|
||||
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"背景视频URL列表"`
|
||||
AudioURL string `json:"audio_url" dc:"背景音频URL(可选)"`
|
||||
Templates []HtmlTemplate `json:"templates" v:"required#模板列表不能为空" dc:"HTML模板列表,每段可指定时间范围和填充数据"`
|
||||
Subtitles []SubtitleSegment `json:"subtitles" dc:"字幕时间线列表(可选)"`
|
||||
SubtitleStyle *SubtitleStyle `json:"subtitle_style" dc:"字幕样式配置(可选)"`
|
||||
CallbackURL string `json:"callback_url" dc:"任务完成后的回调地址(可选)"`
|
||||
}
|
||||
|
||||
// CreateTemplateTaskRes 创建模板任务响应
|
||||
type CreateTemplateTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// ---------- 查询模板任务 ----------
|
||||
|
||||
// GetTemplateTaskReq 查询模板任务请求
|
||||
type GetTemplateTaskReq struct {
|
||||
g.Meta `path:"/template/{taskId}" method:"get" tags:"模板视频" summary:"查询模板视频任务结果" dc:"根据taskId查询模板视频任务详情"`
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetTemplateTaskRes 查询模板任务响应
|
||||
type GetTemplateTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
Status string `json:"status" dc:"任务状态"`
|
||||
FileURL string `json:"fileUrl,omitempty" dc:"输出文件URL"`
|
||||
FileSize int64 `json:"fileSize,omitempty" dc:"输出文件大小"`
|
||||
FileName string `json:"fileName,omitempty" dc:"输出文件名"`
|
||||
DurationStr string `json:"durationStr,omitempty" dc:"视频时长"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
|
||||
}
|
||||
@@ -268,7 +268,8 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
|
||||
|
||||
// runHyperFramesRender 执行 HyperFrames 渲染
|
||||
func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error {
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
timeoutMin := g.Cfg().MustGet(ctx, "hyperframes.render_timeout", 30).Int()
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(timeoutMin)*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// 使用全局 hyperframes 命令(已在 setup 中安装检查)
|
||||
@@ -280,10 +281,45 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
|
||||
// 直接在工作目录运行 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",
|
||||
)
|
||||
|
||||
// 从配置文件读取并构建环境变量
|
||||
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()
|
||||
// 将 TMP/TEMP 设为与工作目录同盘,避免 Windows 跨驱动器符号链接失败
|
||||
tmpDir := filepath.Join(projectDir, "hf_tmp")
|
||||
if absDir, err := filepath.Abs(tmpDir); err == nil {
|
||||
tmpDir = absDir
|
||||
}
|
||||
os.MkdirAll(tmpDir, 0755)
|
||||
env = append(env, "TMP="+tmpDir, "TEMP="+tmpDir, "TMPDIR="+tmpDir)
|
||||
g.Log().Infof(ctx, "[HyperFrames] 设置 TMP=%s (避免跨盘符号链接问题)", tmpDir)
|
||||
if headless {
|
||||
env = append(env, "HYPERFRAMES_HEADLESS=true")
|
||||
}
|
||||
if browserPath != "" {
|
||||
env = append(env, "HYPERFRAMES_BROWSER_PATH="+browserPath)
|
||||
}
|
||||
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
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dao "media/dao/video"
|
||||
@@ -207,12 +208,11 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
|
||||
// detectScenes 调用 Python 脚本进行场景检测
|
||||
func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJSON string, threshold float64) error {
|
||||
// 查找 Python 可执行文件
|
||||
pythonPath, err := exec.LookPath("python3")
|
||||
// Windows 上 python3/python 别名可能指向 Microsoft Store 占位(假 Python),
|
||||
// 需要绕过 WindowsApps 目录并检查常见安装路径
|
||||
pythonPath, err := s.lookupPython(ctx)
|
||||
if err != nil {
|
||||
pythonPath, err = exec.LookPath("python")
|
||||
if err != nil {
|
||||
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]")
|
||||
}
|
||||
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]: %v", err)
|
||||
}
|
||||
|
||||
// 脚本路径:相对于服务运行目录的 scripts/scene_detect.py
|
||||
@@ -247,6 +247,76 @@ func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJ
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupPython 查找真实可用的 Python 可执行文件
|
||||
// Windows 上 %LOCALAPPDATA%\Microsoft\WindowsApps\ 下存在 Store 占位(假 Python),
|
||||
// 需要跳过该目录,优先检查常见安装路径
|
||||
func (s *sceneSplitService) lookupPython(ctx context.Context) (string, error) {
|
||||
// 1. 常见 Python 安装路径(Windows)
|
||||
commonPaths := []string{
|
||||
// 用户安装
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python314", "python.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python313", "python.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python312", "python.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python311", "python.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python310", "python.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python39", "python.exe"),
|
||||
// 系统安装
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python314", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python313", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python312", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python311", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python310", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python313", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python312", "python.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python311", "python.exe"),
|
||||
// C:\Python311 等根目录安装
|
||||
filepath.Join("C:", "Python314", "python.exe"),
|
||||
filepath.Join("C:", "Python313", "python.exe"),
|
||||
filepath.Join("C:", "Python312", "python.exe"),
|
||||
filepath.Join("C:", "Python311", "python.exe"),
|
||||
filepath.Join("C:", "Python310", "python.exe"),
|
||||
// WSL / Git Bash 环境
|
||||
"/usr/bin/python3",
|
||||
"/usr/bin/python",
|
||||
}
|
||||
for _, p := range commonPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
if s.verifyPython(ctx, p) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 PATH 查找,排除 WindowsApps 目录
|
||||
for _, name := range []string{"python3", "python"} {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
// 跳过 Microsoft Store 假占位
|
||||
if strings.Contains(path, "WindowsApps") {
|
||||
g.Log().Warningf(ctx, "[场景检测] 跳过 %s(%s): Microsoft Store 占位,非真实 Python", name, path)
|
||||
continue
|
||||
}
|
||||
if s.verifyPython(ctx, path) {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到可用的 Python 3")
|
||||
}
|
||||
|
||||
// verifyPython 执行 python --version 验证是否真实可用
|
||||
func (s *sceneSplitService) verifyPython(ctx context.Context, path string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
verCmd := exec.CommandContext(ctx, path, "--version")
|
||||
output, err := verCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[场景检测] 尝试 %s 失败(%v): %s", path, err, string(output))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getVideoDurationSeconds 使用 ffprobe 获取音视频时长(秒)
|
||||
func getVideoDurationSeconds(ctx context.Context, videoPath string) float64 {
|
||||
ffprobePath, err := exec.LookPath("ffprobe")
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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个视频")
|
||||
}
|
||||
if len(templates) < 1 {
|
||||
return "", fmt.Errorf("至少需要1个HTML模板")
|
||||
}
|
||||
|
||||
// 创建临时目录用于下载模板和图片
|
||||
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 {
|
||||
g.Log().Infof(ctx, "[模板解析] 处理模板%d: url=%s, start=%.2f, end=%.2f", i, tmpl.URL, tmpl.Start, tmpl.End)
|
||||
elements, err := s.parseSingleTemplate(ctx, tmpl, workDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析模板%d失败: %v", i, err)
|
||||
}
|
||||
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
|
||||
htmlPath, err := downloadFile(ctx, tmpl.URL, downloadDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载模板HTML失败: %v", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
elem.Y = "calc(100% - " + bottom + ")"
|
||||
}
|
||||
|
||||
// 字号
|
||||
if fontSize, ok := styles["font-size"]; ok {
|
||||
elem.FontSize = parsePxToInt(fontSize)
|
||||
}
|
||||
|
||||
// 字体颜色
|
||||
if color, ok := styles["color"]; ok {
|
||||
elem.FontColor = strings.TrimSpace(color)
|
||||
}
|
||||
|
||||
// 背景色 + 透明度
|
||||
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 {
|
||||
elem.Width = parsePxToInt(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 = strings.TrimSuffix(left, "px")
|
||||
|
||||
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 = strings.TrimSuffix(top, "px")
|
||||
|
||||
if top == "0" || top == "0px" {
|
||||
return "top"
|
||||
}
|
||||
if top == "50%" && strings.Contains(transform, "translateY(-50%)") {
|
||||
return "center"
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
/* ========== 全局重置:去掉浏览器默认边距 ========== */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* ========== 画布尺寸 ========== */
|
||||
/*
|
||||
width / height:最终视频的输出分辨率
|
||||
- 当前 1080 x 1920 是竖屏 9:16(手机全屏)
|
||||
- 想改分辨率就改这两个数字
|
||||
例如 720 x 1280 就是 720p 竖屏
|
||||
- overflow: hidden → 超出画布的部分裁掉,不会出滚动条
|
||||
- background: #000 → 背景黑色,视频没铺满时露出的颜色
|
||||
*/
|
||||
body {
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* .clip:所有元素都要有这个 class,才能用 left/top 精确控制位置 */
|
||||
.clip { position: absolute; }
|
||||
|
||||
/* .text-element:所有文字元素都要有这个 class */
|
||||
.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; /* 长单词撑破时强制换行 */
|
||||
}
|
||||
|
||||
/* ========== 入场动画 ========== */
|
||||
/* 给元素加上 class="anim-xxx" 就能用对应的动画效果 */
|
||||
/*
|
||||
动画时长怎么改?
|
||||
数字越小动画越快,越大越慢
|
||||
例如 0.5s → 改成 1s 就慢一倍,改成 0.2s 就快一倍
|
||||
*/
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
|
||||
|
||||
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
|
||||
.anim-slideUp { animation: slideUp 0.6s ease-out; }
|
||||
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
|
||||
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
|
||||
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
id="stage" → 舞台容器,所有元素都放在这里面
|
||||
|
||||
重要属性说明:
|
||||
data-composition-id="main" → 不要改,程序内部标识
|
||||
data-width / data-height → 必须和上面 body 的宽高一致
|
||||
======================================================================
|
||||
-->
|
||||
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
★ data-key 属性 ★
|
||||
每个元素必须有一个 data-key 属性,程序会根据 data-key 的名字,
|
||||
把你传的 data 数据替换到元素内容上。
|
||||
|
||||
例如:
|
||||
模板里 <div data-key="title">标题文字</div>
|
||||
你传参时 data: { "title": "你好世界" }
|
||||
程序就会把"标题文字"替换成"你好世界"
|
||||
|
||||
★ data-start / data-duration 属性 ★
|
||||
data-start="0" → 第 0 秒开始显示
|
||||
data-duration="0" → 持续显示到视频结束(0=一直显示)
|
||||
|
||||
★ data-track-index 属性 ★
|
||||
控制元素的前后层级,数字越大越靠前(盖住数字小的)
|
||||
track-index=0 是背景视频专用,其他元素从 1 开始
|
||||
======================================================================
|
||||
-->
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
data-key="title" 标题
|
||||
|
||||
最终效果:居中靠上,红色大字
|
||||
======================================================================
|
||||
|
||||
★ 你可以改下面这些参数来调整效果 ★
|
||||
|
||||
left: 50% + transform: translateX(-50%)
|
||||
水平居中,不要改
|
||||
|
||||
top: 70px
|
||||
距离顶部 70 像素
|
||||
→ 改大数字,标题往下移;改小数字,标题往上移
|
||||
|
||||
font-size: 48px
|
||||
字号(字体大小)
|
||||
→ 改大数字,字变大;改小数字,字变小
|
||||
|
||||
color: #D82828
|
||||
文字颜色,十六进制颜色码
|
||||
→ 常见颜色:
|
||||
#FFFFFF = 白色 #000000 = 黑色
|
||||
#D82828 = 红色 #333333 = 深灰
|
||||
→ 也可以直接写英文 red / white / black
|
||||
-->
|
||||
<div data-key="title" data-start="0" data-duration="0" data-track-index="1"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%;
|
||||
top: 70px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 48px;
|
||||
color: #D82828;
|
||||
">标题文字</div>
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
data-key="product_image_1" 产品图片
|
||||
|
||||
注意:这是 <img> 图片标签,不是文字
|
||||
|
||||
★ 可调参数 ★
|
||||
|
||||
width: 200px / height: 200px
|
||||
图片的宽和高
|
||||
→ 改大图片变大,改小图片变小
|
||||
|
||||
left: 0px
|
||||
距离左边 0 像素(贴左边缘)
|
||||
→ 改大数字,图片往右移
|
||||
|
||||
top: 200px
|
||||
距离顶部 200 像素
|
||||
→ 改大数字,图片往下移;改小数字,图片往上移
|
||||
|
||||
提示:
|
||||
如果你有多张产品图片,可以复制这个元素,
|
||||
把 data-key 改成 product_image_2、product_image_3 ...
|
||||
然后在传参时给对应的 key 传不同的图片 URL
|
||||
-->
|
||||
<img data-key="product_image_1" data-start="0" data-duration="0" data-track-index="2"
|
||||
class="clip"
|
||||
style="
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
left: 0px;
|
||||
top: 200px;
|
||||
"
|
||||
src="占位图片.jpg" />
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
data-key="product_desc" 产品卖点描述(第一行)
|
||||
|
||||
★ 可调参数 ★
|
||||
|
||||
top: 830px
|
||||
距离顶部 830 像素
|
||||
→ 改大数字,文字往下移;改小数字,文字往上移
|
||||
|
||||
font-size: 34px
|
||||
字号
|
||||
→ 改大字变大,改小字变小
|
||||
|
||||
color: #333333
|
||||
文字颜色
|
||||
→ #333333 = 深灰色
|
||||
→ 改成 #000000 就是纯黑色
|
||||
→ 改成 #FFFFFF 就是白色
|
||||
|
||||
提示:文字内容里可以用 \n 换行
|
||||
-->
|
||||
<div data-key="product_desc" data-start="0" data-duration="0" data-track-index="3"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%;
|
||||
top: 830px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 34px;
|
||||
color: #333333;
|
||||
">产品卖点描述</div>
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
data-key="product_info" 产品信息(第二行描述,更靠下的位置)
|
||||
|
||||
用法和上面的 product_desc 一样,只是位置不同
|
||||
|
||||
★ 可调参数 ★
|
||||
|
||||
top: 950px
|
||||
距离顶部 950 像素
|
||||
→ 比 product_desc 更靠下
|
||||
|
||||
font-size: 28px
|
||||
字号比第一行略小
|
||||
|
||||
color: #444444
|
||||
文字颜色,比第一行稍浅
|
||||
-->
|
||||
<div data-key="product_info" data-start="0" data-duration="0" data-track-index="4"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%;
|
||||
top: 950px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 28px;
|
||||
color: #444444;
|
||||
">第二行描述文字</div>
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
data-key="disclaimer" 底部免责声明
|
||||
|
||||
最终效果:贴在底部,白字黑底半透明
|
||||
|
||||
★ 注意 ★
|
||||
这个元素内部有一个 <span> 标签,背景色写在 span 上,
|
||||
程序会自动识别 span 上的背景色并应用到最终视频。
|
||||
|
||||
★ 可调参数 ★
|
||||
|
||||
top: calc(100% - 45px)
|
||||
距离底部 45 像素(100% 是父容器的全部高度)
|
||||
→ 改大 45 这个数字,文字往上移(离底部更远)
|
||||
→ 改小 45 这个数字,文字往下移(贴底部更近)
|
||||
|
||||
font-size: 20px
|
||||
字号
|
||||
|
||||
color: #FFFFFF
|
||||
文字颜色(白色)
|
||||
|
||||
★ 下面 <span> 标签里的样式 ★
|
||||
|
||||
background: rgba(0, 0, 0, 0.70)
|
||||
黑底半透明背景
|
||||
→ 括号里前三个数字 0,0,0 是黑色(改成 255,255,255 就是白色底)
|
||||
→ 最后一个数字 0.70 是不透明度
|
||||
1.0 = 完全不透明(纯黑底)
|
||||
0.5 = 半透明
|
||||
0.0 = 完全透明(看不见背景)
|
||||
|
||||
padding: 12px 24px
|
||||
背景的内边距
|
||||
→ 上下 12px,左右 24px
|
||||
→ 改大数字,背景范围变大(文字周围的空隙变大)
|
||||
→ 改小数字,背景范围变小
|
||||
|
||||
border-radius: 8px
|
||||
背景圆角
|
||||
→ 改大数字(如 20px),圆角更明显
|
||||
→ 改成 0,就是直角
|
||||
-->
|
||||
<div data-key="disclaimer" data-start="0" data-duration="0" data-track-index="5"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%;
|
||||
top: calc(100% - 45px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 20px;
|
||||
color: #FFFFFF;
|
||||
">
|
||||
<span style="background:rgba(0,0,0,0.70); padding:12px 24px; border-radius:8px;">
|
||||
免责声明文字
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
======================================================================
|
||||
以下脚本不要改,这是程序内部使用的 GSAP 时间轴初始化
|
||||
======================================================================
|
||||
-->
|
||||
<script>
|
||||
(function() {
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to('#stage', { duration: 0, opacity: 1 }, 0);
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines['main'] = tl;
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user