feat(#2001): 爆款标题样式配置面板后端支持 #2004

Merged
auto-approve-bot merged 5 commits from feat/2001-booming-title-style into develop 2026-09-21 03:01:39 +08:00
17 changed files with 463 additions and 3 deletions
@@ -0,0 +1,26 @@
"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config
Revision ID: 083_cover_title_config
Revises: 082_atom_clip_ai_tags
Create Date: 2026-09-20
"""
import sqlalchemy as sa
from alembic import op
revision = "083_cover_title_config"
down_revision = "082_atom_clip_ai_tags"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_avatar_render_jobs",
sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
)
def downgrade() -> None:
op.drop_column("ai_avatar_render_jobs", "cover_title_config")
@@ -63,6 +63,7 @@ def create_render_job(
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
cover_title_config=body.cover_title_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
+7 -1
View File
@@ -53,9 +53,14 @@ class CreateAiAvatarRenderRequest(BaseModel):
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL"
default_factory=dict,
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
cover_title_config: dict[str, Any] = Field(
default_factory=dict,
description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题",
)
project_id: str = Field("", description="项目 ID")
@field_validator("lipsync_job_id")
@@ -83,6 +88,7 @@ class AiAvatarRenderJobResponse(BaseModel):
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置")
status: str
progress: int
output_video_url: str
@@ -61,6 +61,7 @@ class AiAvatarRenderService:
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
cover_title_config: dict[str, Any] | None = None,
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
@@ -112,6 +113,7 @@ class AiAvatarRenderService:
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
cover_title_config=cover_title_config or {},
status="pending",
)
self.db.add(job)
+6
View File
@@ -31,6 +31,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
RUN fc-cache -fv
# 创建虚拟环境
+6
View File
@@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
RUN fc-cache -fv
# 创建虚拟环境
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -750,6 +750,8 @@ class AiAvatarRenderJob(Base):
# b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...]
title_config = Column(JSON, nullable=False, default=dict)
cover_config = Column(JSON, nullable=False, default=dict)
# #2001 封面独立标题配置(结构同 title_config;为空时封面不叠标题)
cover_title_config = Column(JSON, nullable=False, default=dict)
# 任务状态
status = Column(String(20), nullable=False, default="pending", index=True)
+26
View File
@@ -30,6 +30,8 @@ TITLE_MARGIN_SIDE = 40
# - 楷体 → LXGW WenKai(霞鹜文楷,#1896 新增 SIL OFL 开源楷体)
# - 苹方/PingFang/微软雅黑:服务器 Linux 无对应字体,fallback 思源黑体
# - 华康俪金黑:商业字体有版权风险,前端已移除,后端保留映射 fallback 思源黑体(兼容老数据)
# #2001 爆款标题字体(部署到 /usr/share/fonts/truetype/xiaoxia/):
# - 优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy(独立 Black 字重)
FONT_NAME_MAP: dict[str, str] = {
"思源黑体": "Noto Sans SC",
"思源宋体": "Noto Serif CJK SC",
@@ -40,6 +42,15 @@ FONT_NAME_MAP: dict[str, str] = {
"楷体": "LXGW WenKai",
"霞鹜文楷": "LXGW WenKai",
"华康俪金黑": "Noto Sans SC",
# #2001 爆款标题字体
"优设标题黑": "YouSheBiaoTiHei",
"阿里普惠体": "Alibaba PuHuiTi",
"阿里普惠体 Bold": "Alibaba PuHuiTi",
"阿里巴巴普惠体": "Alibaba PuHuiTi",
"抖音美好体": "Douyin Sans",
"抖音体": "Douyin Sans",
"思源黑体 Heavy": "Noto Sans SC",
"思源黑体 Black": "Noto Sans SC",
}
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
@@ -421,6 +432,14 @@ def build_ass_content(
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
# #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签
# line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤
if title_config.get("line_overrides"):
from packages.domain.subtitle_style import SubtitleStyle
_title_style_for_overrides = SubtitleStyle.from_dict(title_config)
safe_title_text = _title_style_for_overrides.apply_line_overrides(safe_title_text)
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
if title_pos is not None:
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
@@ -455,6 +474,13 @@ def build_ass_content(
safe_subtitle_text = escape_ass_text(subtitle_text)
# #2001 逐行样式覆盖(字幕路径同样支持)
if subtitle_config.get("line_overrides"):
from packages.domain.subtitle_style import SubtitleStyle
_sub_style_for_overrides = SubtitleStyle.from_dict(subtitle_config)
safe_subtitle_text = _sub_style_for_overrides.apply_line_overrides(safe_subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{format_ass_time(video_duration)},"
+140 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
@@ -163,6 +163,12 @@ class SubtitleStyle:
fade_out: float = 0.0
animation_type: str = "none"
# 逐行独立样式覆盖(#2001 爆款标题样式面板)
# list[dict],每项可选字段: line_index(0-based,支持负数从末尾倒数),
# color/font/size/bold/italic/stroke_color/stroke_width/shadow_color/shadow_offset_x/shadow_offset_y
# 渲染时按行索引匹配,用 ASS 内联 override 标签包裹该行。缺省字段继承主样式。
line_overrides: list[dict[str, Any]] = field(default_factory=list)
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
"""从字典创建样式配置,带安全类型转换."""
@@ -193,6 +199,14 @@ class SubtitleStyle:
if position not in POSITION_ALIGNMENT:
position = DEFAULT_POSITION
# 逐行覆盖:仅保留 dict 类型项;非 dict 项过滤掉避免渲染崩溃
raw_overrides = config.get("line_overrides") or []
line_overrides: list[dict[str, Any]] = []
if isinstance(raw_overrides, list):
for item in raw_overrides:
if isinstance(item, dict):
line_overrides.append(dict(item))
return cls(
font_name=safe_str("font", DEFAULT_FONT),
font_size=safe_int("size", DEFAULT_FONT_SIZE),
@@ -221,6 +235,7 @@ class SubtitleStyle:
fade_in=max(0.0, safe_float("fade_in", 0.0)),
fade_out=max(0.0, safe_float("fade_out", 0.0)),
animation_type=safe_str("animation_type", "none"),
line_overrides=line_overrides,
)
@property
@@ -248,6 +263,130 @@ class SubtitleStyle:
color_bgr = hex_to_ass_bgr(self.background_color)
return f"&H{alpha_hex}{color_bgr}"
def build_line_override_tag(self, line_index: int, total_lines: int) -> str:
r"""按 line_overrides 配置为指定行构造 ASS 内联 override 标签 {\c&HBBGGRR&...}.
仅返回大括号包裹的 override 标签串;调用方拼到该行文本前即可。
未配置该覆盖项时返回空串。缺省字段继承主样式,不生成对应 tag。
Args:
line_index: 行号(0-based);支持负数(-1 为最后一行)。
total_lines: 总行数(用于解析负数索引)。
"""
if not self.line_overrides or total_lines <= 0:
return ""
# 解析负数索引
resolved = line_index if line_index >= 0 else total_lines + line_index
if resolved < 0 or resolved >= total_lines:
return ""
override: dict[str, Any] | None = None
for item in self.line_overrides:
if not isinstance(item, dict):
continue
idx = item.get("line_index")
try:
idx_int = int(idx) if idx is not None else None
except (TypeError, ValueError):
continue
if idx_int is None:
continue
if idx_int < 0:
idx_int = total_lines + idx_int
if idx_int == resolved:
override = item
break
if not override:
return ""
tags: list[str] = []
# 主色(字体颜色):\c&HBBGGRR&
color_val = override.get("color")
if isinstance(color_val, str) and color_val:
tags.append(f"\\c{hex_to_ass_color(color_val)}")
# 字号:\fsN
size_val = override.get("size") or override.get("font_size")
try:
size_int = int(size_val) if size_val is not None else None
if size_int and size_int > 0:
tags.append(f"\\fs{size_int}")
except (TypeError, ValueError):
pass
# 字体:\fnFontName
font_val = override.get("font") or override.get("font_name")
if isinstance(font_val, str) and font_val:
tags.append(f"\\fn{font_val}")
# 粗体:\b1 / \b0
bold_val = override.get("bold")
if isinstance(bold_val, bool):
tags.append("\\b1" if bold_val else "\\b0")
# 斜体:\i1 / \i0
italic_val = override.get("italic")
if isinstance(italic_val, bool):
tags.append("\\i1" if italic_val else "\\i0")
# 描边色:\3c&HBBGGRR&
stroke_c = override.get("stroke_color")
if isinstance(stroke_c, str) and stroke_c:
tags.append(f"\\3c{hex_to_ass_color(stroke_c)}")
# 描边宽:\bordN
stroke_w = override.get("stroke_width")
try:
sw = float(stroke_w) if stroke_w is not None else None
if sw is not None and sw >= 0:
tags.append(f"\\bord{sw:g}")
except (TypeError, ValueError):
pass
# 阴影色:\4c&HBBGGRR&
shadow_c = override.get("shadow_color")
if isinstance(shadow_c, str) and shadow_c:
tags.append(f"\\4c{hex_to_ass_color(shadow_c)}")
# 阴影偏移:\shadN(单值,同时设置 x/y;精细控制用 \xshad/\yshad
sx = override.get("shadow_offset_x")
sy = override.get("shadow_offset_y")
try:
sx_i = int(sx) if sx is not None else None
sy_i = int(sy) if sy is not None else None
if sx_i is not None:
tags.append(f"\\xshad{sx_i}")
if sy_i is not None:
tags.append(f"\\yshad{sy_i}")
except (TypeError, ValueError):
pass
if not tags:
return ""
return "{" + "".join(tags) + "}"
def apply_line_overrides(self, text: str) -> str:
"""按 line_overrides 对 ASS 文本(已 escape、换行用 \\N 分隔)逐行套 override 标签.
仅对换行后的每行首加对应 override 标签;无 override 的行保持原样。
"""
if not self.line_overrides or not text:
return text
if "\\N" not in text:
# 单行
tag = self.build_line_override_tag(0, 1)
return tag + text if tag else text
lines = text.split("\\N")
total = len(lines)
out: list[str] = []
for i, ln in enumerate(lines):
tag = self.build_line_override_tag(i, total)
out.append(tag + ln if tag else ln)
return "\\N".join(out)
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
+15
View File
@@ -388,6 +388,11 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
"/usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf",
"/usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf",
# #2001 爆款标题字体(优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy)
"/usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf",
"/usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf",
"/usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf",
"/usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
@@ -400,6 +405,7 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
# #1896 字体映射修复:每个字体映射到独立的关键字,而非全部回退到 NotoSansSC
# - 苹方(macOS/ 微软雅黑(Windows/ PingFang:服务器 Linux 无对应文件,fallback 思源黑体
# - 华康俪金黑:商业字体有版权风险,前端已按 #1896 要求移除,后端保留映射但 fallback 思源黑体(兼容老数据)
# #2001 新增爆款标题字体映射
DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansSC",
"思源宋体": "NotoSerifCJKsc",
@@ -410,6 +416,15 @@ DRAWTEXT_FONT_MAP: dict[str, str] = {
"楷体": "LXGWWenKai",
"霞鹜文楷": "LXGWWenKai",
"华康俪金黑": "NotoSansSC",
# #2001 爆款标题字体
"优设标题黑": "YouSheBiaoTiHei",
"阿里普惠体": "AlibabaPuHuiTi-Bold",
"阿里普惠体 Bold": "AlibabaPuHuiTi-Bold",
"阿里巴巴普惠体": "AlibabaPuHuiTi-Bold",
"抖音美好体": "DouyinSansBold",
"抖音体": "DouyinSansBold",
"思源黑体 Heavy": "NotoSansSC-Black",
"思源黑体 Black": "NotoSansSC-Black",
}
@@ -50,6 +50,7 @@ def _make_mock_render_job(
m.b_roll_segments = []
m.title_config = {}
m.cover_config = {}
m.cover_title_config = {}
m.status = status
m.progress = progress
m.output_video_url = output_video_url
@@ -93,6 +94,7 @@ class TestRenderRoutes:
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.cover_title_config = {}
body.project_id = ""
result = create_render_job(
@@ -118,6 +120,7 @@ class TestRenderRoutes:
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.cover_title_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
@@ -141,6 +144,7 @@ class TestRenderRoutes:
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.cover_title_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
+5 -1
View File
@@ -298,6 +298,7 @@ class TestLipsyncServiceUnit:
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
svc.settings.use_gpu_lipsync = False
with pytest.raises(MediaKitError, match="API 调用失败"):
svc.create_job(
@@ -473,6 +474,7 @@ class TestLipsyncServiceUnit:
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=MagicMock(),
)
svc.settings.use_gpu_lipsync = False
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
@@ -616,12 +618,14 @@ class TestSignMediaUrl403Fix:
def _svc(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
return LipsyncService(
svc = LipsyncService(
MagicMock(),
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=MagicMock(),
)
svc.settings.use_gpu_lipsync = False
return svc
def test_own_oss_unsigned_url_gets_resigned(self, mock_mediakit, mock_cosyvoice):
"""裸 public_url(不带签名,私有桶匿名 403)必须被重签."""
+223
View File
@@ -399,3 +399,226 @@ class TestSubtitleSegment:
def test_is_valid_zero_duration(self):
seg = SubtitleSegment(start=1, end=1, text="hello")
assert seg.is_valid is False
# ── line_overrides 逐行样式覆盖测试 ─────────────────────────────────────────
class TestBuildLineOverrideTag:
def test_empty_overrides_returns_empty(self):
style = SubtitleStyle()
assert style.build_line_override_tag(0, 1) == ""
def test_no_matching_line_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 1, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 2) == ""
def test_out_of_range_index_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 5, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 2) == ""
def test_negative_index_resolves_from_end(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": -1, "color": "#FF0000", "font_size": 48}],
}
)
tag = style.build_line_override_tag(-1, 3) # last line, 3 lines total
assert tag
assert "\\c&H000000FF" in tag # red
assert "\\fs48" in tag
assert tag.startswith("{") and tag.endswith("}")
def test_color_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "color": "#00FF00"}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\c&H0000FF00" in tag # green = 00FF00 → bgr 00FF00
def test_font_size_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "font_size": 60}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\fs60" in tag
def test_font_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "font": "优设标题黑"}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\fn优设标题黑" in tag
def test_bold_on_off(self):
style_bold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": True}]})
assert "\\b1" in style_bold.build_line_override_tag(0, 1)
style_nobold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": False}]})
assert "\\b0" in style_nobold.build_line_override_tag(0, 1)
def test_italic_on_off(self):
style_italic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": True}]})
assert "\\i1" in style_italic.build_line_override_tag(0, 1)
style_noitalic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": False}]})
assert "\\i0" in style_noitalic.build_line_override_tag(0, 1)
def test_stroke_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 0, "stroke_color": "#0000FF", "stroke_width": 3.0}],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\3c&H00FF0000" in tag # blue bgr
assert "\\bord3" in tag
def test_shadow_override(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{"line_index": 0, "shadow_color": "#000000", "shadow_offset_x": 2, "shadow_offset_y": 3}
],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\4c&H00000000" in tag # black
assert "\\xshad2" in tag
assert "\\yshad3" in tag
def test_full_combo(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{
"line_index": 0,
"color": "#FF0000",
"font_size": 72,
"font": "抖音美好体",
"bold": True,
"italic": False,
"stroke_color": "#FFFFFF",
"stroke_width": 2,
"shadow_color": "#000000",
"shadow_offset_x": 0,
"shadow_offset_y": 4,
}
],
}
)
tag = style.build_line_override_tag(0, 1)
assert "\\c&H000000FF" in tag
assert "\\fs72" in tag
assert "\\fn抖音美好体" in tag
assert "\\b1" in tag
assert "\\i0" in tag
assert "\\3c&H00FFFFFF" in tag
assert "\\bord2" in tag
assert "\\4c&H00000000" in tag
assert "\\xshad0" in tag
assert "\\yshad4" in tag
def test_empty_override_dict_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0}]})
assert style.build_line_override_tag(0, 1) == ""
def test_non_dict_items_filtered(self):
# 从 dict 解析时已过滤,这里手动构造测试
style = SubtitleStyle(line_overrides=["not a dict", {"line_index": 0, "color": "#FF0000"}])
tag = style.build_line_override_tag(0, 1)
assert "\\c&H000000FF" in tag
def test_total_lines_zero_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
assert style.build_line_override_tag(0, 0) == ""
def test_line_index_none_skipped(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"color": "#FF0000"}]}) # 无 line_index
assert style.build_line_override_tag(0, 1) == ""
class TestApplyLineOverrides:
def test_empty_text_returns_empty(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
assert style.apply_line_overrides("") == ""
def test_no_overrides_returns_original(self):
style = SubtitleStyle()
assert style.apply_line_overrides("hello\\Nworld") == "hello\\Nworld"
def test_single_line(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 60}]})
result = style.apply_line_overrides("单行标题")
assert result.startswith("{")
assert "单行标题" in result
assert "\\c&H000000FF" in result
def test_multi_line_only_first_line_tagged(self):
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 72}]})
result = style.apply_line_overrides("第一行\\N第二行\\N第三行")
lines = result.split("\\N")
assert len(lines) == 3
assert lines[0].startswith("{\\c&H000000FF")
assert "第一行" in lines[0]
assert lines[1] == "第二行" # 无标签
assert lines[2] == "第三行"
def test_multi_line_middle_and_last(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [
{"line_index": 0, "font_size": 72, "bold": True},
{"line_index": -1, "color": "#00FF00", "font_size": 36},
],
}
)
result = style.apply_line_overrides("主标题\\N副标题\\N脚注")
lines = result.split("\\N")
assert lines[0].startswith("{\\fs72\\b1}")
assert lines[1] == "副标题"
assert "\\c&H0000FF00" in lines[2]
assert "\\fs36" in lines[2]
def test_line_without_override_kept_verbatim(self):
style = SubtitleStyle.from_dict(
{
"line_overrides": [{"line_index": 1, "bold": True}],
}
)
result = style.apply_line_overrides("第一行\\N第二行")
lines = result.split("\\N")
assert lines[0] == "第一行"
assert lines[1].startswith("{\\b1}")
assert "第二行" in lines[1]
class TestFromDictLineOverrides:
def test_from_dict_parses_line_overrides(self):
cfg = {
"line_overrides": [
{"line_index": 0, "color": "#FF0000", "bold": True},
{"line_index": 1, "font_size": 36},
],
}
style = SubtitleStyle.from_dict(cfg)
assert len(style.line_overrides) == 2
assert style.line_overrides[0]["line_index"] == 0
assert style.line_overrides[1]["font_size"] == 36
def test_from_dict_filters_non_dict_items(self):
cfg = {"line_overrides": [{"line_index": 0, "color": "#FF0000"}, "bad", None, 123]}
style = SubtitleStyle.from_dict(cfg)
assert len(style.line_overrides) == 1
def test_from_dict_no_field_defaults_empty(self):
style = SubtitleStyle.from_dict({"font": "微软雅黑"})
assert style.line_overrides == []
def test_default_has_empty_list(self):
style = SubtitleStyle()
assert style.line_overrides == []