From ecb41269cdfb040224ed08173f6b2bb81136e7d8 Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Tue, 7 Jul 2026 16:02:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=A8=A1=E7=89=88=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/video/template_controller.go | 56 ++++ main.go | 2 + model/dto/video/video_template_dto.go | 50 ++++ service/video/caption_service.go | 46 ++- service/video/scene_split_service.go | 80 ++++- service/video/template_service.go | 375 ++++++++++++++++++++++++ template_example.html | 302 +++++++++++++++++++ 7 files changed, 901 insertions(+), 10 deletions(-) create mode 100644 controller/video/template_controller.go create mode 100644 model/dto/video/video_template_dto.go create mode 100644 service/video/template_service.go create mode 100644 template_example.html diff --git a/controller/video/template_controller.go b/controller/video/template_controller.go new file mode 100644 index 0000000..f59773c --- /dev/null +++ b/controller/video/template_controller.go @@ -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 +} diff --git a/main.go b/main.go index 7a03a94..3ff3267 100644 --- a/main.go +++ b/main.go @@ -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 {} } diff --git a/model/dto/video/video_template_dto.go b/model/dto/video/video_template_dto.go new file mode 100644 index 0000000..5d5a688 --- /dev/null +++ b/model/dto/video/video_template_dto.go @@ -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:"错误信息"` +} diff --git a/service/video/caption_service.go b/service/video/caption_service.go index 983be2c..0719cab 100644 --- a/service/video/caption_service.go +++ b/service/video/caption_service.go @@ -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 { diff --git a/service/video/scene_split_service.go b/service/video/scene_split_service.go index 202aaef..ed785d5 100644 --- a/service/video/scene_split_service.go +++ b/service/video/scene_split_service.go @@ -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") diff --git a/service/video/template_service.go b/service/video/template_service.go new file mode 100644 index 0000000..a83cdb1 --- /dev/null +++ b/service/video/template_service.go @@ -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 "" +} diff --git a/template_example.html b/template_example.html new file mode 100644 index 0000000..b19574e --- /dev/null +++ b/template_example.html @@ -0,0 +1,302 @@ + + + + + + + + + + + +
+ + + + +
标题文字
+ + + + + +
产品卖点描述
+ + +
第二行描述文字
+ + +
+ + 免责声明文字 + +
+ +
+ + + + + +