根据模版渲染视频

This commit is contained in:
lmk
2026-07-07 16:02:31 +08:00
parent b740e400c9
commit ecb41269cd
7 changed files with 901 additions and 10 deletions
+56
View File
@@ -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
View File
@@ -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 {}
}
+50
View File
@@ -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:"错误信息"`
}
+41 -5
View File
@@ -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 {
+75 -5
View File
@@ -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")
+375
View File
@@ -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 ""
}
+302
View File
@@ -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>