From dca668a56b48f0df31b73b71ab439a914e9d309b Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Mon, 17 Aug 2026 17:36:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=A8=A1=E6=9D=BF=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- model/dto/video/video_caption_dto.go | 5 +- service/video/caption_service.go | 41 +++- service/video/template_service.go | 47 ++++- template_example.html | 241 +++++++++++++-------- template_example_heng.html | 302 +++++++++++++++++++++++++++ 5 files changed, 532 insertions(+), 104 deletions(-) create mode 100644 template_example_heng.html diff --git a/model/dto/video/video_caption_dto.go b/model/dto/video/video_caption_dto.go index ee36cf0..93304fc 100644 --- a/model/dto/video/video_caption_dto.go +++ b/model/dto/video/video_caption_dto.go @@ -15,11 +15,14 @@ type CaptionElement struct { Y string `json:"y" d:"center" dc:"垂直位置:top/center/bottom或像素值"` FontSize int `json:"fontSize" d:"36" dc:"字号(type=text有效)"` FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色"` + FontStroke string `json:"fontStroke" dc:"字体描边,如3px #000000(空=无描边)"` BgColor string `json:"bgColor" dc:"背景色,如#FFFF00,空=透明"` BgOpacity float64 `json:"bgOpacity" d:"1.0" dc:"背景透明度0-1"` - Width int `json:"width" dc:"元素宽度(type=image有效)"` + Width int `json:"width" dc:"元素宽度(px)"` + WidthStr string `json:"widthStr" dc:"宽度表达式(如calc(100% - 320px),非整数宽度用,type=text优先于width)"` Height int `json:"height" dc:"元素高度(type=image有效)"` Animation string `json:"animation" dc:"动画效果:fadeIn/slideUp/slideLeft/scaleIn"` + Vertical bool `json:"vertical" dc:"竖排显示(type=text有效,如左右两侧竖排文案,渲染为writing-mode:vertical-rl)"` TrackIndex int `json:"trackIndex" d:"1" dc:"轨道层级(数字越大越靠前)"` } diff --git a/service/video/caption_service.go b/service/video/caption_service.go index f39da40..a113760 100644 --- a/service/video/caption_service.go +++ b/service/video/caption_service.go @@ -587,18 +587,34 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca // 文字样式 if elem.Type == "text" { - // 自动计算 max-width 防止文字溢出画布 - // 底部居中字幕: 90% 宽度; 左/右贴边: 45% 宽度 - maxWidthPct := 90 - switch elem.X { - case "left", "right": - maxWidthPct = 45 - case "center", "": - maxWidthPct = 90 + if elem.Vertical { + // 竖排显示:writing-mode 竖排,宽度自动收缩为单列,不强制百分比宽度 + parts = append(parts, "writing-mode:vertical-rl;") + } else { + if elem.WidthStr != "" { + // 模板指定了非整数宽度(如 calc(100% - 320px)):文字宽度随画布自适应, + // 始终落在两图之间的空隙里,避免压到图片 + parts = append(parts, fmt.Sprintf("width:%s;", elem.WidthStr)) + parts = append(parts, fmt.Sprintf("max-width:%s;", elem.WidthStr)) + } else if elem.Width > 0 { + // 模板指定了像素宽度:文字宽度精确受控,不再按画布百分比 + parts = append(parts, fmt.Sprintf("width:%dpx;", elem.Width)) + parts = append(parts, fmt.Sprintf("max-width:%dpx;", elem.Width)) + } else { + // 自动计算 max-width 防止文字溢出画布 + // 底部居中字幕: 90% 宽度; 左/右贴边: 45% 宽度 + maxWidthPct := 90 + switch elem.X { + case "left", "right": + maxWidthPct = 45 + case "center", "": + maxWidthPct = 90 + } + maxWidth := int(float64(canvasWidth) * float64(maxWidthPct) / 100.0) + parts = append(parts, fmt.Sprintf("width:%dpx;", maxWidth)) + parts = append(parts, fmt.Sprintf("max-width:%dpx;", maxWidth)) + } } - maxWidth := int(float64(canvasWidth) * float64(maxWidthPct) / 100.0) - parts = append(parts, fmt.Sprintf("width:%dpx;", maxWidth)) - parts = append(parts, fmt.Sprintf("max-width:%dpx;", maxWidth)) if elem.FontSize > 0 { parts = append(parts, fmt.Sprintf("font-size:%dpx;", elem.FontSize)) @@ -606,6 +622,9 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca if elem.FontColor != "" { parts = append(parts, fmt.Sprintf("color:%s;", elem.FontColor)) } + if elem.FontStroke != "" { + parts = append(parts, fmt.Sprintf("-webkit-text-stroke:%s;", elem.FontStroke)) + } } return strings.Join(parts, "") diff --git a/service/video/template_service.go b/service/video/template_service.go index 1dc7d3f..ff8f41f 100644 --- a/service/video/template_service.go +++ b/service/video/template_service.go @@ -230,7 +230,22 @@ func (s *templateService) applyStyles(elem *dto.CaptionElement, styles map[strin elem.Y = cssPositionToY(top, styles) } else if bottom, ok := styles["bottom"]; ok { bottom = strings.TrimSpace(bottom) - elem.Y = "calc(100% - " + 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 + } } // 字号 @@ -243,6 +258,11 @@ func (s *templateService) applyStyles(elem *dto.CaptionElement, styles map[strin elem.FontColor = strings.TrimSpace(color) } + // 字体描边(如 -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) @@ -266,7 +286,13 @@ func (s *templateService) applyStyles(elem *dto.CaptionElement, styles map[strin // 图片宽高 if w, ok := styles["width"]; ok && elem.Width <= 0 { - elem.Width = parsePxToInt(w) + 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) @@ -298,7 +324,7 @@ func parseInlineStyle(style string) map[string]string { // cssPositionToX 将 CSS left/transform 转为 X 定位值 func cssPositionToX(left string, styles map[string]string) string { transform := styles["transform"] - left = strings.TrimSuffix(left, "px") + left = stripPx(left) if left == "0" || left == "0px" { return "left" @@ -315,7 +341,7 @@ func cssPositionToX(left string, styles map[string]string) string { // cssPositionToY 将 CSS top/transform 转为 Y 定位值 func cssPositionToY(top string, styles map[string]string) string { transform := styles["transform"] - top = strings.TrimSuffix(top, "px") + top = stripPx(top) if top == "0" || top == "0px" { return "top" @@ -326,6 +352,19 @@ func cssPositionToY(top string, styles map[string]string) string { 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) diff --git a/template_example.html b/template_example.html index b19574e..fb10fc3 100644 --- a/template_example.html +++ b/template_example.html @@ -13,6 +13,9 @@ - 当前 1080 x 1920 是竖屏 9:16(手机全屏) - 想改分辨率就改这两个数字 例如 720 x 1280 就是 720p 竖屏 + - 注意:这里只是"预览用"尺寸,实际渲染时画布高度会跟随 + 你上传的视频分辨率变化,所以模板里贴底/贴右请用 calc(100% - Npx), + 不要写死像素值。 - overflow: hidden → 超出画布的部分裁掉,不会出滚动条 - background: #000 → 背景黑色,视频没铺满时露出的颜色 */ @@ -89,7 +92,34 @@ body { ★ data-track-index 属性 ★ 控制元素的前后层级,数字越大越靠前(盖住数字小的) track-index=0 是背景视频专用,其他元素从 1 开始 - ====================================================================== + + ★★ 渲染器 CSS 支持范围(写模板前必看,避免踩坑)★★ + 模板只是一份"配置",服务端会把它解析成有限的元素模型,再重新生成 + HTML 渲染,因此不是所有 CSS 都会被保留。请遵守以下规则: + + 1. 定位支持:left / right / top / bottom 关键字、纯像素值、 + calc(...) 表达式;文字支持 writing-mode: vertical-rl 竖排。 + + 2. ★ 不要用 bottom: Npx 给"有高度"的元素定位 ★ + 它会被翻译成 top: calc(100% - Npx),把元素顶边放到画布 + 100%-N 的位置,图片会整体掉出画面(表现为"图片丢失")。 + 想让元素贴底,请写 top: calc(100% - 元素高度px); + 想离底 Npx,请写 top: calc(100% - 元素高度px - Npx)。 + + 3. ★ right: Npx 的偏移量会被忽略 ★(等价 right:0,直接贴右边) + 想让元素距右边 Npx,请写 left: calc(100% - Npx - 元素宽度px)。 + + 4. 竖排文字配 top: 50% + transform: translateY(-50%) 可垂直居中; + 水平居中仍用 left: 50% + transform: translateX(-50%)。 + + 5. 文字描边支持 -webkit-text-stroke: Npx #000000 + (例如白字一圈黑边:-webkit-text-stroke: 1px #000000。 + ★ 描边别太粗:中文字笔画细,28px 字号下 3px 描边会把白色 + 填充盖成黑字;1px 稳妥,想明显可加到 2px。) + + 6. 文字也支持 width(模板指定 width 可覆盖渲染端的默认宽度, + 精确控制换行范围,如把文字限定在两张图片之间不压图)。 + ====================================================================== -->
+
+
+